Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Fan
7636437244 codex: make create-api-key app-server backed
Co-authored-by: Codex <noreply@openai.com>
2026-04-05 18:07:48 -04:00
34 changed files with 2453 additions and 254 deletions

View File

@@ -2518,6 +2518,28 @@
],
"type": "object"
},
"ThreadCreateApiKeyFinishParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadCreateApiKeyStartParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadForkParams": {
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",
"properties": {
@@ -3581,6 +3603,54 @@
"title": "Thread/unsubscribeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/start"
],
"title": "Thread/createApiKey/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadCreateApiKeyStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/finish"
],
"title": "Thread/createApiKey/finishRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadCreateApiKeyFinishParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/finishRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -362,6 +362,54 @@
"title": "Thread/unsubscribeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/start"
],
"title": "Thread/createApiKey/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadCreateApiKeyStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/finish"
],
"title": "Thread/createApiKey/finishRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadCreateApiKeyFinishParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/finishRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12342,6 +12390,111 @@
"title": "ThreadCompactStartResponse",
"type": "object"
},
"ThreadCreateApiKeyFinishParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyFinishParams",
"type": "object"
},
"ThreadCreateApiKeyFinishResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"defaultProjectId": {
"type": "string"
},
"defaultProjectTitle": {
"type": [
"string",
"null"
]
},
"organizationId": {
"type": "string"
},
"organizationTitle": {
"type": [
"string",
"null"
]
},
"projectApiKey": {
"type": "string"
}
},
"required": [
"defaultProjectId",
"organizationId",
"projectApiKey"
],
"title": "ThreadCreateApiKeyFinishResponse",
"type": "object"
},
"ThreadCreateApiKeyStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyStartParams",
"type": "object"
},
"ThreadCreateApiKeyStartResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"oneOf": [
{
"properties": {
"status": {
"enum": [
"alreadySet"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponseStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponse",
"type": "object"
},
{
"properties": {
"auth_url": {
"type": "string"
},
"callback_port": {
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"status": {
"enum": [
"started"
],
"type": "string"
}
},
"required": [
"auth_url",
"callback_port",
"status"
],
"type": "object"
}
],
"title": "ThreadCreateApiKeyStartResponse"
},
"ThreadForkParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",

View File

@@ -937,6 +937,54 @@
"title": "Thread/unsubscribeRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/start"
],
"title": "Thread/createApiKey/startRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadCreateApiKeyStartParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/createApiKey/finish"
],
"title": "Thread/createApiKey/finishRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadCreateApiKeyFinishParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/createApiKey/finishRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -10197,6 +10245,111 @@
"title": "ThreadCompactStartResponse",
"type": "object"
},
"ThreadCreateApiKeyFinishParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyFinishParams",
"type": "object"
},
"ThreadCreateApiKeyFinishResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"defaultProjectId": {
"type": "string"
},
"defaultProjectTitle": {
"type": [
"string",
"null"
]
},
"organizationId": {
"type": "string"
},
"organizationTitle": {
"type": [
"string",
"null"
]
},
"projectApiKey": {
"type": "string"
}
},
"required": [
"defaultProjectId",
"organizationId",
"projectApiKey"
],
"title": "ThreadCreateApiKeyFinishResponse",
"type": "object"
},
"ThreadCreateApiKeyStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyStartParams",
"type": "object"
},
"ThreadCreateApiKeyStartResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"oneOf": [
{
"properties": {
"status": {
"enum": [
"alreadySet"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponseStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponse",
"type": "object"
},
{
"properties": {
"auth_url": {
"type": "string"
},
"callback_port": {
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"status": {
"enum": [
"started"
],
"type": "string"
}
},
"required": [
"auth_url",
"callback_port",
"status"
],
"type": "object"
}
],
"title": "ThreadCreateApiKeyStartResponse"
},
"ThreadForkParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.",

View File

@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyFinishParams",
"type": "object"
}

View File

@@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"defaultProjectId": {
"type": "string"
},
"defaultProjectTitle": {
"type": [
"string",
"null"
]
},
"organizationId": {
"type": "string"
},
"organizationTitle": {
"type": [
"string",
"null"
]
},
"projectApiKey": {
"type": "string"
}
},
"required": [
"defaultProjectId",
"organizationId",
"projectApiKey"
],
"title": "ThreadCreateApiKeyFinishResponse",
"type": "object"
}

View File

@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadCreateApiKeyStartParams",
"type": "object"
}

View File

@@ -0,0 +1,46 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"oneOf": [
{
"properties": {
"status": {
"enum": [
"alreadySet"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponseStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "AlreadySetv2::ThreadCreateApiKeyStartResponse",
"type": "object"
},
{
"properties": {
"auth_url": {
"type": "string"
},
"callback_port": {
"format": "uint16",
"minimum": 0.0,
"type": "integer"
},
"status": {
"enum": [
"started"
],
"type": "string"
}
},
"required": [
"auth_url",
"callback_port",
"status"
],
"type": "object"
}
],
"title": "ThreadCreateApiKeyStartResponse"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadCreateApiKeyFinishParams = { threadId: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadCreateApiKeyFinishResponse = { organizationId: string, organizationTitle: string | null, defaultProjectId: string, defaultProjectTitle: string | null, projectApiKey: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadCreateApiKeyStartParams = { threadId: string, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadCreateApiKeyStartResponse = { "status": "alreadySet" } | { "status": "started", auth_url: string, callback_port: number, };

View File

@@ -273,6 +273,10 @@ export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
export type { ThreadClosedNotification } from "./ThreadClosedNotification";
export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadCreateApiKeyFinishParams } from "./ThreadCreateApiKeyFinishParams";
export type { ThreadCreateApiKeyFinishResponse } from "./ThreadCreateApiKeyFinishResponse";
export type { ThreadCreateApiKeyStartParams } from "./ThreadCreateApiKeyStartParams";
export type { ThreadCreateApiKeyStartResponse } from "./ThreadCreateApiKeyStartResponse";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
export type { ThreadItem } from "./ThreadItem";

View File

@@ -259,6 +259,14 @@ client_request_definitions! {
params: v2::ThreadUnsubscribeParams,
response: v2::ThreadUnsubscribeResponse,
},
ThreadCreateApiKeyStart => "thread/createApiKey/start" {
params: v2::ThreadCreateApiKeyStartParams,
response: v2::ThreadCreateApiKeyStartResponse,
},
ThreadCreateApiKeyFinish => "thread/createApiKey/finish" {
params: v2::ThreadCreateApiKeyFinishParams,
response: v2::ThreadCreateApiKeyFinishResponse,
},
#[experimental("thread/increment_elicitation")]
/// Increment the thread-local out-of-band elicitation counter.
///

View File

@@ -2891,6 +2891,42 @@ pub struct ThreadUnarchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadSetNameResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadCreateApiKeyStartParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", tag = "status")]
#[ts(export_to = "v2/", tag = "status")]
pub enum ThreadCreateApiKeyStartResponse {
AlreadySet,
Started {
auth_url: String,
callback_port: u16,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadCreateApiKeyFinishParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadCreateApiKeyFinishResponse {
pub organization_id: String,
pub organization_title: Option<String>,
pub default_project_id: String,
pub default_project_title: Option<String>,
pub project_api_key: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -139,6 +139,8 @@ Example with notification opt-out:
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/createApiKey/start` — start browser-based project API key creation for a loaded thread. If `OPENAI_API_KEY` is already available in the server process or thread dependency env, returns `{ "status": "alreadySet" }`; otherwise returns `{ "status": "started", "authUrl": "...", "callbackPort": 5000 }`.
- `thread/createApiKey/finish` — wait for the browser OAuth callback, mint a project API key, attach it to the thread dependency env for future spawned commands, and return the key plus org/project labels.
- `thread/status/changed` — notification emitted when a loaded threads status changes (`threadId` + new `status`).
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
@@ -391,6 +393,32 @@ Use `thread/metadata/update` to patch sqlite-backed metadata for a thread withou
} }
```
### Example: Create an API key for a thread
Use `thread/createApiKey/start` to start browser OAuth for project API key creation, then `thread/createApiKey/finish` to wait for completion and attach the minted key to that thread's dependency env.
```json
{ "method": "thread/createApiKey/start", "id": 26, "params": {
"threadId": "thr_123"
} }
{ "id": 26, "result": {
"status": "started",
"authUrl": "https://auth.openai.com/oauth/authorize?...",
"callbackPort": 5000
} }
{ "method": "thread/createApiKey/finish", "id": 27, "params": {
"threadId": "thr_123"
} }
{ "id": 27, "result": {
"organizationId": "org_...",
"organizationTitle": "Personal",
"defaultProjectId": "proj_...",
"defaultProjectTitle": "Default project",
"projectApiKey": "sk-proj-..."
} }
```
### Example: Archive a thread
Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory.

View File

@@ -120,6 +120,10 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadCreateApiKeyFinishParams;
use codex_app_server_protocol::ThreadCreateApiKeyFinishResponse;
use codex_app_server_protocol::ThreadCreateApiKeyStartParams;
use codex_app_server_protocol::ThreadCreateApiKeyStartResponse;
use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadForkParams;
@@ -237,6 +241,8 @@ use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_login::PendingCreateApiKey;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
@@ -245,6 +251,7 @@ use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
use codex_login::start_create_api_key;
use codex_mcp::mcp::auth::discover_supported_scopes;
use codex_mcp::mcp::auth::resolve_oauth_scopes;
use codex_mcp::mcp::collect_mcp_snapshot;
@@ -362,6 +369,11 @@ enum ActiveLogin {
},
}
struct ActiveCreateApiKey {
thread_id: ThreadId,
pending: Option<PendingCreateApiKey>,
}
impl ActiveLogin {
fn login_id(&self) -> Uuid {
match self {
@@ -403,6 +415,24 @@ impl Drop for ActiveLogin {
}
}
impl Drop for ActiveCreateApiKey {
fn drop(&mut self) {
if let Some(pending) = self.pending.as_ref() {
pending.shutdown();
}
}
}
fn effective_openai_api_key_is_set(
dependency_env: &HashMap<String, String>,
process_env_value: Option<String>,
) -> bool {
match dependency_env.get(OPENAI_API_KEY_ENV_VAR) {
Some(value) => !value.trim().is_empty(),
None => process_env_value.is_some_and(|value| !value.trim().is_empty()),
}
}
/// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs).
pub(crate) struct CodexMessageProcessor {
auth_manager: Arc<AuthManager>,
@@ -415,6 +445,7 @@ pub(crate) struct CodexMessageProcessor {
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
active_create_api_key: Arc<Mutex<Option<ActiveCreateApiKey>>>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
@@ -548,6 +579,7 @@ impl CodexMessageProcessor {
runtime_feature_enablement,
cloud_requirements,
active_login: Arc::new(Mutex::new(None)),
active_create_api_key: Arc::new(Mutex::new(None)),
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
thread_state_manager: ThreadStateManager::new(),
thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing),
@@ -742,6 +774,14 @@ impl CodexMessageProcessor {
self.thread_set_name(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadCreateApiKeyStart { request_id, params } => {
self.thread_create_api_key_start(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadCreateApiKeyFinish { request_id, params } => {
self.thread_create_api_key_finish(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadMetadataUpdate { request_id, params } => {
self.thread_metadata_update(to_connection_request_id(request_id), params)
.await;
@@ -2707,6 +2747,161 @@ impl CodexMessageProcessor {
.await;
}
async fn thread_create_api_key_start(
&self,
request_id: ConnectionRequestId,
params: ThreadCreateApiKeyStartParams,
) {
let ThreadCreateApiKeyStartParams { thread_id } = params;
let (thread_uuid, thread) = match self.load_thread(&thread_id).await {
Ok(loaded) => loaded,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let dependency_env = thread.dependency_env().await;
if effective_openai_api_key_is_set(
&dependency_env,
std::env::var(OPENAI_API_KEY_ENV_VAR).ok(),
) {
self.outgoing
.send_response(request_id, ThreadCreateApiKeyStartResponse::AlreadySet)
.await;
return;
}
let pending = match start_create_api_key() {
Ok(pending) => pending,
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to start API key creation: {err}"),
)
.await;
return;
}
};
let auth_url = pending.auth_url().to_string();
let callback_port = pending.callback_port();
let mut active_create_api_key = self.active_create_api_key.lock().await;
*active_create_api_key = Some(ActiveCreateApiKey {
thread_id: thread_uuid,
pending: Some(pending),
});
drop(active_create_api_key);
self.outgoing
.send_response(
request_id,
ThreadCreateApiKeyStartResponse::Started {
auth_url,
callback_port,
},
)
.await;
}
async fn thread_create_api_key_finish(
&self,
request_id: ConnectionRequestId,
params: ThreadCreateApiKeyFinishParams,
) {
let ThreadCreateApiKeyFinishParams { thread_id } = params;
let (thread_uuid, thread) = match self.load_thread(&thread_id).await {
Ok(loaded) => loaded,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let pending = {
let mut active_create_api_key = self.active_create_api_key.lock().await;
let Some(mut active) = active_create_api_key.take() else {
drop(active_create_api_key);
self.send_invalid_request_error(
request_id,
format!("no active API key creation flow for thread {thread_uuid}"),
)
.await;
return;
};
if active.thread_id != thread_uuid {
let active_thread_id = active.thread_id;
*active_create_api_key = Some(active);
drop(active_create_api_key);
self.send_invalid_request_error(
request_id,
format!(
"active API key creation flow belongs to thread {active_thread_id}, not thread {thread_uuid}"
),
)
.await;
return;
}
let Some(pending) = active.pending.take() else {
drop(active_create_api_key);
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"active API key creation flow for thread {thread_uuid} has no pending session"
),
data: None,
},
)
.await;
return;
};
pending
};
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
let result = pending.finish().await;
match result {
Ok(created) => {
let response = ThreadCreateApiKeyFinishResponse {
organization_id: created.organization_id,
organization_title: created.organization_title,
default_project_id: created.default_project_id,
default_project_title: created.default_project_title,
project_api_key: created.project_api_key.clone(),
};
thread
.set_dependency_env(HashMap::from([(
OPENAI_API_KEY_ENV_VAR.to_string(),
created.project_api_key,
)]))
.await;
outgoing.send_response(request_id, response).await;
}
Err(err) => {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("API key creation failed: {err}"),
data: None,
},
)
.await;
}
}
});
}
async fn thread_metadata_update(
&self,
request_id: ConnectionRequestId,
@@ -9046,6 +9241,26 @@ mod tests {
);
}
#[test]
fn effective_openai_api_key_uses_dependency_env_precedence() {
assert!(effective_openai_api_key_is_set(
&HashMap::new(),
Some("sk-process".to_string())
));
assert!(!effective_openai_api_key_is_set(
&HashMap::new(),
/*process_env_value*/ None
));
assert!(effective_openai_api_key_is_set(
&HashMap::from([(OPENAI_API_KEY_ENV_VAR.to_string(), "sk-session".to_string())]),
/*process_env_value*/ None,
));
assert!(!effective_openai_api_key_is_set(
&HashMap::from([(OPENAI_API_KEY_ENV_VAR.to_string(), String::new())]),
Some("sk-process".to_string()),
));
}
#[test]
fn collect_resume_override_mismatches_includes_service_tier() {
let request = ThreadResumeParams {

View File

@@ -22,6 +22,7 @@ use codex_protocol::protocol::Submission;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::UserInput;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::sync::watch;
@@ -123,6 +124,14 @@ impl CodexThread {
self.codex.agent_status().await
}
pub async fn dependency_env(&self) -> HashMap<String, String> {
self.codex.session.dependency_env().await
}
pub async fn set_dependency_env(&self, values: HashMap<String, String>) {
self.codex.session.set_dependency_env(values).await;
}
pub(crate) fn subscribe_status(&self) -> watch::Receiver<AgentStatus> {
self.codex.agent_status.clone()
}

View File

@@ -24,6 +24,28 @@ pub fn create_env(
create_env_from_vars(std::env::vars(), policy, thread_id)
}
/// Apply session dependency environment after normal filtering.
///
/// Dependency values are user/session-provided inputs required by tools or
/// generated during a session, so they intentionally override the default
/// secret-looking environment filters for future spawned commands.
pub(crate) fn apply_dependency_env(
env: &mut HashMap<String, String>,
explicit_env_overrides: &mut HashMap<String, String>,
dependency_env: &HashMap<String, String>,
) {
if dependency_env.is_empty() {
return;
}
env.extend(dependency_env.clone());
for key in dependency_env.keys() {
if let Some(value) = env.get(key) {
explicit_env_overrides.insert(key.clone(), value.clone());
}
}
}
fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,

View File

@@ -103,6 +103,26 @@ fn test_set_overrides() {
assert_eq!(result, expected);
}
#[test]
fn apply_dependency_env_restores_filtered_secret_and_marks_explicit_override() {
let mut env = hashmap! {
"PATH".to_string() => "/usr/bin".to_string(),
};
let mut explicit_env_overrides = HashMap::new();
let dependency_env = hashmap! {
"OPENAI_API_KEY".to_string() => "sk-session".to_string(),
};
apply_dependency_env(&mut env, &mut explicit_env_overrides, &dependency_env);
let expected = hashmap! {
"PATH".to_string() => "/usr/bin".to_string(),
"OPENAI_API_KEY".to_string() => "sk-session".to_string(),
};
assert_eq!(env, expected);
assert_eq!(explicit_env_overrides, dependency_env);
}
#[test]
fn populate_env_inserts_thread_id() {
let vars = make_vars(&[("PATH", "/usr/bin")]);

View File

@@ -12,6 +12,7 @@ use crate::codex::TurnContext;
use crate::exec::ExecCapturePolicy;
use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::exec_env::apply_dependency_env;
use crate::exec_env::create_env;
use crate::sandboxing::ExecRequest;
use crate::state::TaskKind;
@@ -123,11 +124,18 @@ pub(crate) async fn execute_user_shell_command(
let use_login_shell = true;
let session_shell = session.user_shell();
let display_command = session_shell.derive_exec_args(&command, use_login_shell);
let mut explicit_env_overrides = turn_context.shell_environment_policy.r#set.clone();
let dependency_env = session.dependency_env().await;
let mut env = create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
);
apply_dependency_env(&mut env, &mut explicit_env_overrides, &dependency_env);
let exec_command = maybe_wrap_shell_lc_with_snapshot(
&display_command,
session_shell.as_ref(),
turn_context.cwd.as_path(),
&turn_context.shell_environment_policy.r#set,
&explicit_env_overrides,
);
let call_id = Uuid::new_v4().to_string();
@@ -155,10 +163,7 @@ pub(crate) async fn execute_user_shell_command(
let exec_env = ExecRequest {
command: exec_command.clone(),
cwd: cwd.to_path_buf(),
env: create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
),
env,
network: turn_context.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.

View File

@@ -7,6 +7,7 @@ use std::sync::Arc;
use crate::codex::TurnContext;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_env::apply_dependency_env;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::function_tool::FunctionCallError;
@@ -395,17 +396,13 @@ impl ShellHandler {
} = args;
let mut exec_params = exec_params;
let dependency_env = session.dependency_env().await;
if !dependency_env.is_empty() {
exec_params.env.extend(dependency_env.clone());
}
let mut explicit_env_overrides = turn.shell_environment_policy.r#set.clone();
for key in dependency_env.keys() {
if let Some(value) = exec_params.env.get(key) {
explicit_env_overrides.insert(key.clone(), value.clone());
}
}
let dependency_env = session.dependency_env().await;
apply_dependency_env(
&mut exec_params.env,
&mut explicit_env_overrides,
&dependency_env,
);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);

View File

@@ -12,6 +12,7 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::exec_env::apply_dependency_env;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecRequest;
@@ -649,10 +650,13 @@ impl UnifiedExecProcessManager {
cwd: PathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
let dependency_env = context.session.dependency_env().await;
let mut env = apply_unified_exec_env(create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
let mut explicit_env_overrides = context.turn.shell_environment_policy.r#set.clone();
apply_dependency_env(&mut env, &mut explicit_env_overrides, &dependency_env);
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(
self,
@@ -680,7 +684,7 @@ impl UnifiedExecProcessManager {
process_id: request.process_id,
cwd,
env,
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
explicit_env_overrides,
network: request.network.clone(),
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,

View File

@@ -0,0 +1,531 @@
//! Browser-based OAuth flow for creating OpenAI project API keys.
use std::time::Duration;
use codex_client::build_reqwest_client_with_custom_ca;
use reqwest::Client;
use reqwest::Method;
use serde::Deserialize;
use url::Url;
use crate::oauth_callback_server::AuthorizationCodeServer;
use crate::oauth_callback_server::PortConflictStrategy;
use crate::oauth_callback_server::start_authorization_code_server;
use crate::pkce::PkceCodes;
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 API_BASE: &str = "https://api.openai.com";
// This client is registered with Hydra for http://localhost:5000/auth/callback,
// so the browser redirect must stay on port 5000.
const CALLBACK_PORT: u16 = 5000;
const CALLBACK_PATH: &str = "/auth/callback";
const SCOPE: &str = "openid email profile offline_access";
const APP: &str = "api";
const USER_AGENT: &str = "Codex-Create-API-Key/1.0";
const PROJECT_API_KEY_NAME: &str = "Codex CLI";
const PROJECT_POLL_INTERVAL_SECONDS: u64 = 10;
const PROJECT_POLL_TIMEOUT_SECONDS: u64 = 60;
const OAUTH_TIMEOUT_SECONDS: u64 = 15 * 60;
const HTTP_TIMEOUT_SECONDS: u64 = 30;
#[derive(Debug, Clone, PartialEq, Eq)]
struct CreateApiKeyOptions {
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,
}
pub struct PendingCreateApiKey {
client: Client,
options: CreateApiKeyOptions,
redirect_uri: String,
code_verifier: String,
callback_server: AuthorizationCodeServer,
}
impl PendingCreateApiKey {
pub fn auth_url(&self) -> &str {
&self.callback_server.auth_url
}
pub fn callback_port(&self) -> u16 {
self.callback_server.actual_port
}
pub fn open_browser(&self) -> bool {
self.callback_server.open_browser()
}
pub fn shutdown(&self) {
self.callback_server.shutdown();
}
pub async fn finish(self) -> Result<CreatedApiKey, CreateApiKeyError> {
let code = self
.callback_server
.wait_for_code(Duration::from_secs(OAUTH_TIMEOUT_SECONDS))
.await
.map_err(|err| CreateApiKeyError::message(err.to_string()))?;
create_api_key_from_authorization_code(
&self.client,
&self.options,
&self.redirect_uri,
&self.code_verifier,
&code,
)
.await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedApiKey {
pub organization_id: String,
pub organization_title: Option<String>,
pub default_project_id: String,
pub default_project_title: Option<String>,
pub project_api_key: String,
}
pub fn start_create_api_key() -> Result<PendingCreateApiKey, CreateApiKeyError> {
let options = CreateApiKeyOptions {
issuer: AUTH_ISSUER.to_string(),
client_id: PLATFORM_HYDRA_CLIENT_ID.to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: API_BASE.to_string(),
app: APP.to_string(),
callback_port: CALLBACK_PORT,
scope: SCOPE.to_string(),
api_key_name: PROJECT_API_KEY_NAME.to_string(),
project_poll_interval_seconds: PROJECT_POLL_INTERVAL_SECONDS,
project_poll_timeout_seconds: PROJECT_POLL_TIMEOUT_SECONDS,
};
let client = build_http_client()?;
let callback_server = start_authorization_code_server(
options.callback_port,
PortConflictStrategy::Fail,
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| CreateApiKeyError::message(err.to_string()))?;
let redirect_uri = callback_server.redirect_uri.clone();
Ok(PendingCreateApiKey {
client,
options,
redirect_uri,
code_verifier: callback_server.code_verifier().to_string(),
callback_server,
})
}
fn build_authorize_url(
options: &CreateApiKeyOptions,
redirect_uri: &str,
pkce: &PkceCodes,
state: &str,
) -> Result<String, CreateApiKeyError> {
let mut url = Url::parse(&format!(
"{}/oauth/authorize",
options.issuer.trim_end_matches('/')
))
.map_err(|err| CreateApiKeyError::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<Client, CreateApiKeyError> {
build_reqwest_client_with_custom_ca(
reqwest::Client::builder().timeout(Duration::from_secs(HTTP_TIMEOUT_SECONDS)),
)
.map_err(|err| CreateApiKeyError::message(format!("failed to build HTTP client: {err}")))
}
async fn create_api_key_from_authorization_code(
client: &Client,
options: &CreateApiKeyOptions,
redirect_uri: &str,
code_verifier: &str,
code: &str,
) -> Result<CreatedApiKey, CreateApiKeyError> {
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(CreatedApiKey {
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,
})
}
async fn exchange_authorization_code_for_tokens(
client: &Client,
issuer: &str,
client_id: &str,
redirect_uri: &str,
code_verifier: &str,
code: &str,
) -> Result<OAuthTokens, CreateApiKeyError> {
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::CONTENT_TYPE,
"application/x-www-form-urlencoded",
)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.body(format!(
"client_id={}&code_verifier={}&code={}&grant_type={}&redirect_uri={}",
urlencoding::encode(client_id),
urlencoding::encode(code_verifier),
urlencoding::encode(code),
urlencoding::encode("authorization_code"),
urlencoding::encode(redirect_uri)
)),
"POST",
&url,
)
.await
}
async fn onboarding_login(
client: &Client,
api_base: &str,
app: &str,
access_token: &str,
) -> Result<OnboardingLoginResponse, CreateApiKeyError> {
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, 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<Vec<Organization>, CreateApiKeyError> {
let url = format!("{}/v1/organizations", api_base.trim_end_matches('/'));
let response: DataList<Organization> = execute_json(
client
.request(Method::GET, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, 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<Vec<Project>, CreateApiKeyError> {
let url = format!(
"{}/dashboard/organizations/{}/projects?detail=basic&limit=100",
api_base.trim_end_matches('/'),
urlencoding::encode(organization_id)
);
let response: DataList<Project> = execute_json(
client
.request(Method::GET, &url)
.header(reqwest::header::ACCEPT, "application/json")
.header(reqwest::header::USER_AGENT, 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<ProjectApiKeyTarget, CreateApiKeyError> {
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 organizations.is_empty() {
"no organization found".to_string()
} else {
let ordered_organizations = organizations_by_preference(&organizations);
let mut project_count = 0;
for organization in ordered_organizations {
let projects =
list_projects(client, api_base, session_key, &organization.id).await?;
project_count += projects.len();
if let Some(project) = find_default_project(&projects) {
return Ok(ProjectApiKeyTarget {
organization_id: organization.id.clone(),
organization_title: organization.title.clone(),
project_id: project.id.clone(),
project_title: project.title.clone(),
});
}
}
format!(
"checked {} organizations and {} projects, but no default project is ready yet.",
organizations.len(),
project_count
)
};
if std::time::Instant::now() >= deadline {
return Err(CreateApiKeyError::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));
tokio::time::sleep(Duration::from_secs(sleep_seconds)).await;
}
}
fn organizations_by_preference(organizations: &[Organization]) -> Vec<&Organization> {
let mut ordered_organizations = organizations.iter().enumerate().collect::<Vec<_>>();
ordered_organizations.sort_by_key(|(index, organization)| {
let rank = if organization.is_default {
0
} else if organization.personal {
1
} else {
2
};
(rank, *index)
});
ordered_organizations
.into_iter()
.map(|(_, organization)| organization)
.collect()
}
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: &ProjectApiKeyTarget,
key_name: &str,
) -> Result<CreateProjectApiKeyResponse, CreateApiKeyError> {
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, USER_AGENT)
.bearer_auth(session_key)
.json(&serde_json::json!({
"action": "create",
"name": key_name,
})),
"POST",
&url,
)
.await
}
async fn execute_json<T>(
request: reqwest::RequestBuilder,
method: &str,
url: &str,
) -> Result<T, CreateApiKeyError>
where
T: for<'de> Deserialize<'de>,
{
let response = request
.send()
.await
.map_err(|err| CreateApiKeyError::message(format!("Network error calling {url}: {err}")))?;
let status = response.status();
let body = response.bytes().await.map_err(|err| {
CreateApiKeyError::message(format!("Failed reading response from {url}: {err}"))
})?;
if !status.is_success() {
return Err(CreateApiKeyError::api(
format!("{method} {url} failed with HTTP {status}"),
String::from_utf8_lossy(&body).into_owned(),
));
}
serde_json::from_slice(&body)
.map_err(|err| CreateApiKeyError::message(format!("{url} returned invalid JSON: {err}")))
}
#[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<T> {
data: Vec<T>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct Organization {
id: String,
title: Option<String>,
#[serde(default)]
is_default: bool,
#[serde(default)]
personal: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
struct Project {
id: String,
title: Option<String>,
#[serde(default)]
is_initial: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProjectApiKeyTarget {
organization_id: String,
organization_title: Option<String>,
project_id: String,
project_title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateProjectApiKeyResponse {
key: CreatedProjectApiKey,
}
#[derive(Debug, Deserialize)]
struct CreatedProjectApiKey {
sensitive_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateApiKeyError {
message: String,
}
impl CreateApiKeyError {
fn message(message: String) -> Self {
Self { message }
}
fn api(message: String, body: String) -> Self {
Self {
message: format!("{message}: {body}"),
}
}
}
impl std::fmt::Display for CreateApiKeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CreateApiKeyError {}
#[cfg(test)]
#[path = "create_api_key_tests.rs"]
mod tests;

View File

@@ -0,0 +1,182 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_string_contains;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
#[test]
fn organizations_by_preference_orders_default_then_personal_then_input_order() {
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 = organizations_by_preference(&organizations);
assert_eq!(
selected,
vec![&organizations[2], &organizations[1], &organizations[0]]
);
}
#[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));
}
#[tokio::test]
async fn create_api_key_from_authorization_code_creates_api_key() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(header("content-type", "application/x-www-form-urlencoded"))
.and(body_string_contains("client_id=client-123"))
.and(body_string_contains("code_verifier=verifier-123"))
.and(body_string_contains("code=auth-code-123"))
.and(body_string_contains("grant_type=authorization_code"))
.and(body_string_contains(
"redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fauth%2Fcallback",
))
.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,
},
{
"id": "org-secondary",
"title": "Secondary Org",
}
]
})))
.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": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/dashboard/organizations/org-secondary/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-secondary/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 = CreateApiKeyOptions {
issuer: server.uri(),
client_id: "client-123".to_string(),
audience: PLATFORM_AUDIENCE.to_string(),
api_base: server.uri(),
app: APP.to_string(),
callback_port: CALLBACK_PORT,
scope: SCOPE.to_string(),
api_key_name: 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 = create_api_key_from_authorization_code(
&client,
&options,
"http://localhost:5000/auth/callback",
"verifier-123",
"auth-code-123",
)
.await
.expect("provision");
assert_eq!(
output,
CreatedApiKey {
organization_id: "org-secondary".to_string(),
organization_title: Some("Secondary 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(),
}
);
}

View File

@@ -4,18 +4,24 @@ pub mod auth_env_telemetry;
pub mod provider_auth;
pub mod token_data;
mod create_api_key;
mod device_code_auth;
mod oauth_callback_server;
mod pkce;
mod server;
pub use codex_client::BuildCustomCaTransportError as BuildLoginHttpClientError;
pub use create_api_key::CreateApiKeyError;
pub use create_api_key::CreatedApiKey;
pub use create_api_key::PendingCreateApiKey;
pub use create_api_key::start_create_api_key;
pub use device_code_auth::DeviceCode;
pub use device_code_auth::complete_device_code_login;
pub use device_code_auth::request_device_code;
pub use device_code_auth::run_device_code_login;
pub use oauth_callback_server::ShutdownHandle;
pub use server::LoginServer;
pub use server::ServerOptions;
pub use server::ShutdownHandle;
pub use server::run_login_server;
pub use api_bridge::auth_provider_from_auth;

View File

@@ -0,0 +1,475 @@
//! Shared localhost OAuth callback server machinery.
//!
//! This module owns the reusable bind/listen/response loop used by OAuth-style browser flows.
use std::future::Future;
use std::io::Cursor;
use std::io::Read;
use std::io::Write;
use std::io::{self};
use std::net::SocketAddr;
use std::net::TcpStream;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use base64::Engine;
use rand::RngCore;
use tiny_http::Header;
use tiny_http::Request;
use tiny_http::Response;
use tiny_http::Server;
use tiny_http::StatusCode;
use crate::pkce::PkceCodes;
use crate::pkce::generate_pkce;
/// Strategy for handling a callback port that is already in use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PortConflictStrategy {
/// Attempt to cancel a previous callback server on the same port and retry.
CancelPrevious,
/// Return an error immediately without sending any request to the occupied port.
Fail,
}
/// Handle used to signal the callback server loop to exit.
#[derive(Clone, Debug)]
pub struct ShutdownHandle {
shutdown_notify: Arc<tokio::sync::Notify>,
}
impl ShutdownHandle {
/// Signals the server loop to terminate.
pub fn shutdown(&self) {
self.shutdown_notify.notify_waiters();
}
}
/// 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 code_verifier(&self) -> &str {
&self.code_verifier
}
pub(crate) fn shutdown(&self) {
self.shutdown_handle.shutdown();
}
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 flow timed out waiting for the browser callback.",
))
}
}
}
}
pub(crate) fn start_authorization_code_server<F>(
port: u16,
port_conflict_strategy: PortConflictStrategy,
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, actual_port, rx) = bind_server_with_request_channel(port, port_conflict_strategy)?;
let redirect_uri = format!("http://localhost:{actual_port}{callback_path}");
let auth_url = match auth_url_builder(&redirect_uri, &pkce, &state) {
Ok(auth_url) => auth_url,
Err(err) => {
server.unblock();
return Err(err);
}
};
let (server_handle, shutdown_handle) = spawn_callback_server_loop(
server,
rx,
"Authentication was not completed",
move |url_raw| {
let callback_path = callback_path.clone();
let state = state.clone();
async move { process_authorization_code_request(&url_raw, &callback_path, &state) }
},
);
Ok(AuthorizationCodeServer {
auth_url,
actual_port,
redirect_uri,
code_verifier: pkce.code_verifier,
server_handle,
shutdown_handle,
})
}
/// Internal callback handling outcome.
pub(crate) enum HandledRequest<T> {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
status: StatusCode,
headers: Vec<Header>,
body: Vec<u8>,
result: io::Result<T>,
},
}
pub(crate) fn bind_server_with_request_channel(
port: u16,
port_conflict_strategy: PortConflictStrategy,
) -> io::Result<(Arc<Server>, u16, tokio::sync::mpsc::Receiver<Request>)> {
let server = bind_server(port, port_conflict_strategy)?;
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);
// Map blocking reads from server.recv() to an async channel.
let (tx, 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(())
})
};
Ok((server, actual_port, rx))
}
pub(crate) fn spawn_callback_server_loop<T, F, Fut>(
server: Arc<Server>,
mut rx: tokio::sync::mpsc::Receiver<Request>,
incomplete_message: &'static str,
mut process_request: F,
) -> (tokio::task::JoinHandle<io::Result<T>>, ShutdownHandle)
where
T: Send + 'static,
F: FnMut(String) -> Fut + Send + 'static,
Fut: Future<Output = HandledRequest<T>> + Send + 'static,
{
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let server_handle = {
let shutdown_notify = shutdown_notify.clone();
tokio::spawn(async move {
let result = loop {
tokio::select! {
_ = shutdown_notify.notified() => {
break Err(io::Error::other(incomplete_message));
}
maybe_req = rx.recv() => {
let Some(req) = maybe_req else {
break Err(io::Error::other(incomplete_message));
};
let url_raw = req.url().to_string();
let response = process_request(url_raw).await;
if let Some(result) = respond_to_request(req, response).await {
break result;
}
}
}
};
// Ensure that the server is unblocked so the thread dedicated to
// running `server.recv()` in a loop exits cleanly.
server.unblock();
result
})
};
(server_handle, ShutdownHandle { shutdown_notify })
}
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
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_https response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// 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 mut writer = req.into_writer();
let reason = status.default_reason_phrase();
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
headers.retain(|h| !h.field.equiv("Connection"));
if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
headers.push(close_header);
}
let content_length_value = format!("{}", body.len());
if let Ok(content_length_header) =
Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
{
headers.push(content_length_header);
}
for header in headers {
write!(
writer,
"{}: {}\r\n",
header.field.as_str(),
header.value.as_str()
)?;
}
writer.write_all(b"\r\n")?;
writer.write_all(&body)?;
writer.flush()
}
pub(crate) 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)
}
fn send_cancel_request(port: u16) -> io::Result<()> {
let addr: SocketAddr = format!("127.0.0.1:{port}")
.parse()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
stream.write_all(b"Connection: close\r\n\r\n")?;
let mut buf = [0u8; 64];
let _ = stream.read(&mut buf);
Ok(())
}
fn bind_server(port: u16, port_conflict_strategy: PortConflictStrategy) -> io::Result<Server> {
let bind_address = format!("127.0.0.1:{port}");
let mut cancel_attempted = false;
let mut attempts = 0;
const MAX_ATTEMPTS: u32 = 10;
const RETRY_DELAY: Duration = Duration::from_millis(200);
loop {
match Server::http(&bind_address) {
Ok(server) => return Ok(server),
Err(err) => {
attempts += 1;
let is_addr_in_use = err
.downcast_ref::<io::Error>()
.map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
.unwrap_or(false);
// If the address is in use, there is probably another instance of the callback
// server running. Attempt to cancel it and retry.
if is_addr_in_use {
if port_conflict_strategy == PortConflictStrategy::Fail {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("Port {bind_address} is already in use"),
));
}
if !cancel_attempted {
cancel_attempted = true;
if let Err(cancel_err) = send_cancel_request(port) {
eprintln!("Failed to cancel previous callback server: {cancel_err}");
}
}
thread::sleep(RETRY_DELAY);
if attempts >= MAX_ATTEMPTS {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("Port {bind_address} is already in use"),
));
}
continue;
}
return Err(io::Error::other(err));
}
}
}
}
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"Authentication cancelled".to_vec(),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Authentication 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 = authorization_code_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>Authentication 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(),
}
}
fn authorization_code_error_message(error_code: &str, error_description: Option<&str>) -> String {
if let Some(description) = error_description
&& !description.trim().is_empty()
{
return format!("Sign-in failed: {description}");
}
format!("Sign-in failed: {error_code}")
}

View File

@@ -11,18 +11,10 @@
//! This module therefore keeps the user-facing error path and the structured-log path separate.
//! Returned `io::Error` values still carry the detail needed by CLI/browser callers, while
//! structured logs only emit explicitly reviewed fields plus redacted URL/error values.
use std::io::Cursor;
use std::io::Read;
use std::io::Write;
use std::io::{self};
use std::net::SocketAddr;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;
use std::thread;
use std::time::Duration;
use crate::auth::AuthCredentialsStoreMode;
use crate::auth::AuthDotJson;
@@ -37,17 +29,21 @@ use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_utils_template::Template;
use rand::RngCore;
use serde_json::Value as JsonValue;
use tiny_http::Header;
use tiny_http::Request;
use tiny_http::Response;
use tiny_http::Server;
use tiny_http::StatusCode;
use tracing::error;
use tracing::info;
use tracing::warn;
use crate::oauth_callback_server::HandledRequest;
use crate::oauth_callback_server::PortConflictStrategy;
use crate::oauth_callback_server::ShutdownHandle;
use crate::oauth_callback_server::bind_server_with_request_channel;
use crate::oauth_callback_server::generate_state;
use crate::oauth_callback_server::spawn_callback_server_loop;
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
const DEFAULT_PORT: u16 = 1455;
static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
@@ -116,36 +112,13 @@ impl LoginServer {
}
}
/// Handle used to signal the login server loop to exit.
#[derive(Clone, Debug)]
pub struct ShutdownHandle {
shutdown_notify: Arc<tokio::sync::Notify>,
}
impl ShutdownHandle {
/// Signals the login loop to terminate.
pub fn shutdown(&self) {
self.shutdown_notify.notify_waiters();
}
}
/// 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();
let state = opts.force_state.clone().unwrap_or_else(generate_state);
let server = bind_server(opts.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 (server, actual_port, rx) =
bind_server_with_request_channel(opts.port, PortConflictStrategy::CancelPrevious)?;
let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
let auth_url = build_authorize_url(
&opts.issuer,
@@ -159,100 +132,25 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
if opts.open_browser {
let _ = webbrowser::open(&auth_url);
}
// Map blocking reads from server.recv() to an async channel.
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"));
}
}
let (server_handle, shutdown_handle) =
spawn_callback_server_loop(server, rx, "Login was not completed", move |url_raw| {
let redirect_uri = redirect_uri.clone();
let state = state.clone();
let opts = opts.clone();
let pkce = pkce.clone();
async move {
process_request(&url_raw, &opts, &redirect_uri, &pkce, actual_port, &state).await
}
Ok(())
})
};
let shutdown_notify = Arc::new(tokio::sync::Notify::new());
let server_handle = {
let shutdown_notify = shutdown_notify.clone();
let server = server;
tokio::spawn(async move {
let result = loop {
tokio::select! {
_ = shutdown_notify.notified() => {
break Err(io::Error::other("Login was not completed"));
}
maybe_req = rx.recv() => {
let Some(req) = maybe_req else {
break Err(io::Error::other("Login was not completed"));
};
let url_raw = req.url().to_string();
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 {
break result;
}
}
}
};
// Ensure that the server is unblocked so the thread dedicated to
// running `server.recv()` in a loop exits cleanly.
server.unblock();
result
})
};
});
Ok(LoginServer {
auth_url,
actual_port,
server_handle,
shutdown_handle: ShutdownHandle { shutdown_notify },
shutdown_handle,
})
}
/// Internal callback handling outcome.
enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
headers: Vec<Header>,
body: Vec<u8>,
result: io::Result<()>,
},
}
async fn process_request(
url_raw: &str,
opts: &ServerOptions,
@@ -260,7 +158,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) => {
@@ -398,6 +296,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"[..],
@@ -410,6 +309,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(
@@ -421,50 +321,6 @@ async fn process_request(
}
}
/// 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
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_https response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// server-side connection persistence, but it does not.
fn send_response_with_disconnect(
req: Request,
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)?;
headers.retain(|h| !h.field.equiv("Connection"));
if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
headers.push(close_header);
}
let content_length_value = format!("{}", body.len());
if let Ok(content_length_header) =
Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
{
headers.push(content_length_header);
}
for header in headers {
write!(
writer,
"{}: {}\r\n",
header.field.as_str(),
header.value.as_str()
)?;
}
writer.write_all(b"\r\n")?;
writer.write_all(&body)?;
writer.flush()
}
fn build_authorize_url(
issuer: &str,
client_id: &str,
@@ -503,74 +359,6 @@ fn build_authorize_url(
format!("{issuer}/oauth/authorize?{qs}")
}
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)
}
fn send_cancel_request(port: u16) -> io::Result<()> {
let addr: SocketAddr = format!("127.0.0.1:{port}")
.parse()
.map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(2))?;
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
stream.set_write_timeout(Some(Duration::from_secs(2)))?;
stream.write_all(b"GET /cancel HTTP/1.1\r\n")?;
stream.write_all(format!("Host: 127.0.0.1:{port}\r\n").as_bytes())?;
stream.write_all(b"Connection: close\r\n\r\n")?;
let mut buf = [0u8; 64];
let _ = stream.read(&mut buf);
Ok(())
}
fn bind_server(port: u16) -> io::Result<Server> {
let bind_address = format!("127.0.0.1:{port}");
let mut cancel_attempted = false;
let mut attempts = 0;
const MAX_ATTEMPTS: u32 = 10;
const RETRY_DELAY: Duration = Duration::from_millis(200);
loop {
match Server::http(&bind_address) {
Ok(server) => return Ok(server),
Err(err) => {
attempts += 1;
let is_addr_in_use = err
.downcast_ref::<io::Error>()
.map(|io_err| io_err.kind() == io::ErrorKind::AddrInUse)
.unwrap_or(false);
// If the address is in use, there is probably another instance of the login server
// running. Attempt to cancel it and retry.
if is_addr_in_use {
if !cancel_attempted {
cancel_attempted = true;
if let Err(cancel_err) = send_cancel_request(port) {
eprintln!("Failed to cancel previous login server: {cancel_err}");
}
}
thread::sleep(RETRY_DELAY);
if attempts >= MAX_ATTEMPTS {
return Err(io::Error::new(
io::ErrorKind::AddrInUse,
format!("Port {bind_address} is already in use"),
));
}
continue;
}
return Err(io::Error::other(err));
}
}
}
}
/// Tokens returned by the OAuth authorization-code exchange.
pub(crate) struct ExchangedTokens {
pub id_token: String,
@@ -894,13 +682,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

@@ -4275,6 +4275,13 @@ impl App {
self.enqueue_thread_history_entry_response(thread_id, event)
.await?;
}
AppEvent::StartCreateApiKey { thread_id } => {
crate::create_api_key::start_command(
self.app_event_tx.clone(),
app_server.request_handle(),
thread_id,
);
}
AppEvent::DiffResult(text) => {
// Clear the in-progress state in the bottom pane
self.chat_widget.on_diff_complete();

View File

@@ -157,6 +157,11 @@ pub(crate) enum AppEvent {
/// Result of computing a `/diff` command.
DiffResult(String),
/// Start the browser flow for creating an OpenAI API key for a thread.
StartCreateApiKey {
thread_id: ThreadId,
},
/// Open the app link view in the bottom pane.
OpenAppLink {
app_id: String,

View File

@@ -5244,6 +5244,16 @@ impl ChatWidget {
}
}
}
SlashCommand::CreateApiKey => {
let Some(thread_id) = self.thread_id else {
self.add_error_message(
"No active Codex thread for API key creation.".to_string(),
);
return;
};
self.app_event_tx
.send(AppEvent::StartCreateApiKey { thread_id });
}
SlashCommand::Mention => {
self.insert_str("@");
}

View File

@@ -0,0 +1,328 @@
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadCreateApiKeyFinishParams;
use codex_app_server_protocol::ThreadCreateApiKeyFinishResponse;
use codex_app_server_protocol::ThreadCreateApiKeyStartParams;
use codex_app_server_protocol::ThreadCreateApiKeyStartResponse;
use codex_login::CreatedApiKey;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_protocol::ThreadId;
use ratatui::style::Stylize;
use ratatui::text::Line;
use uuid::Uuid;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::clipboard_text;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
pub(crate) fn start_command(
app_event_tx: AppEventSender,
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
) {
tokio::spawn(async move {
let cell =
start_create_api_key_command(thread_id, request_handle, app_event_tx.clone()).await;
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(cell)));
});
}
async fn start_create_api_key_command(
thread_id: ThreadId,
request_handle: AppServerRequestHandle,
app_event_tx: AppEventSender,
) -> PlainHistoryCell {
let response = match start_create_api_key_flow(thread_id, &request_handle).await {
Ok(response) => response,
Err(err) => {
return history_cell::new_error_event(format!(
"Failed to start API key creation: {err}"
));
}
};
let ThreadCreateApiKeyStartResponse::Started {
auth_url,
callback_port,
} = response
else {
return existing_api_key_message();
};
let browser_opened = webbrowser::open(&auth_url).is_ok();
let start_message = continue_in_browser_message(&auth_url, callback_port, browser_opened);
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(start_message)));
complete_command(thread_id, request_handle).await
}
async fn start_create_api_key_flow(
thread_id: ThreadId,
request_handle: &AppServerRequestHandle,
) -> Result<ThreadCreateApiKeyStartResponse, String> {
let request = ClientRequest::ThreadCreateApiKeyStart {
request_id: create_api_key_request_id(),
params: ThreadCreateApiKeyStartParams {
thread_id: thread_id.to_string(),
},
};
request_handle
.request_typed::<ThreadCreateApiKeyStartResponse>(request)
.await
.map_err(|err| err.to_string())
}
fn existing_api_key_message() -> PlainHistoryCell {
history_cell::new_info_event(
format!(
"{OPENAI_API_KEY_ENV_VAR} is already set in this Codex session; skipping API key creation."
),
Some(format!(
"Unset {OPENAI_API_KEY_ENV_VAR} and run /create-api-key again if you want Codex to create a different key."
)),
)
}
fn continue_in_browser_message(
auth_url: &str,
callback_port: u16,
browser_opened: bool,
) -> PlainHistoryCell {
let mut lines = vec![
vec![
"".dim(),
"Finish API key creation via your browser.".into(),
]
.into(),
"".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 API key creation 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(),
auth_url.to_string().cyan().underlined(),
]));
lines.push("".into());
lines.push(
format!(
" Codex will display the new {OPENAI_API_KEY_ENV_VAR} here and copy it to your clipboard."
)
.dark_gray()
.into(),
);
lines.push("".into());
lines.push(
format!(
" On a remote or headless machine, forward localhost:{callback_port} back to this Codex host before opening the link."
)
.dark_gray()
.into(),
);
PlainHistoryCell::new(lines)
}
async fn complete_command(
thread_id: ThreadId,
request_handle: AppServerRequestHandle,
) -> PlainHistoryCell {
let created = match finish_create_api_key_flow(thread_id, &request_handle).await {
Ok(created) => created,
Err(err) => {
return history_cell::new_error_event(format!("API key creation failed: {err}"));
}
};
let copy_result = clipboard_text::copy_text_to_clipboard(&created.project_api_key);
success_cell(&created, copy_result)
}
async fn finish_create_api_key_flow(
thread_id: ThreadId,
request_handle: &AppServerRequestHandle,
) -> Result<CreatedApiKey, String> {
let request = ClientRequest::ThreadCreateApiKeyFinish {
request_id: create_api_key_request_id(),
params: ThreadCreateApiKeyFinishParams {
thread_id: thread_id.to_string(),
},
};
request_handle
.request_typed::<ThreadCreateApiKeyFinishResponse>(request)
.await
.map(|response| CreatedApiKey {
organization_id: response.organization_id,
organization_title: response.organization_title,
default_project_id: response.default_project_id,
default_project_title: response.default_project_title,
project_api_key: response.project_api_key,
})
.map_err(|err| err.to_string())
}
fn success_cell(created: &CreatedApiKey, copy_result: Result<(), String>) -> PlainHistoryCell {
let organization = created
.organization_title
.clone()
.unwrap_or_else(|| created.organization_id.clone());
let project = created
.default_project_title
.clone()
.unwrap_or_else(|| created.default_project_id.clone());
let masked_api_key = mask_api_key(&created.project_api_key);
let copy_status = match copy_result {
Ok(()) => "I copied the full key to your clipboard.".to_string(),
Err(err) => format!("Could not copy the key to your clipboard: {err}."),
};
PlainHistoryCell::new(vec![
vec![
"".dim(),
format!("Created an API key for {organization} / {project}: {masked_api_key}.").into(),
]
.into(),
vec![
" ".into(),
format!(
"{copy_status} I also set {OPENAI_API_KEY_ENV_VAR} in this Codex session for future commands."
)
.into(),
]
.into(),
"".into(),
vec![
" ".into(),
"To create more keys or monitor usage, go to platform.openai.com.".dark_gray(),
]
.into(),
"".into(),
vec![
" ".into(),
"You can start building with the OpenAI API with limited usage of gpt-5.4-nano. To use more models, add credits on platform.openai.com."
.dark_gray(),
]
.into(),
])
}
fn mask_api_key(api_key: &str) -> String {
const UNMASKED_PREFIX_LEN: usize = 8;
const UNMASKED_SUFFIX_LEN: usize = 4;
let chars = api_key.chars().collect::<Vec<_>>();
if chars.len() <= UNMASKED_PREFIX_LEN + UNMASKED_SUFFIX_LEN {
return api_key.to_string();
}
let prefix = chars[..UNMASKED_PREFIX_LEN].iter().collect::<String>();
let suffix = chars[chars.len() - UNMASKED_SUFFIX_LEN..]
.iter()
.collect::<String>();
format!("{prefix}...{suffix}")
}
fn create_api_key_request_id() -> RequestId {
RequestId::String(Uuid::new_v4().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use pretty_assertions::assert_eq;
#[test]
fn success_cell_renders_expected_copy() {
let cell = success_cell(
&CreatedApiKey {
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-1234567890".to_string(),
},
Ok(()),
);
assert_eq!(
render_cell(&cell),
"• Created an API key for Default Org / Default Project: sk-proj-...7890.\n I copied the full key to your clipboard. I also set OPENAI_API_KEY in this Codex session for future commands.\n\n To create more keys or monitor usage, go to platform.openai.com.\n\n You can start building with the OpenAI API with limited usage of gpt-5.4-nano. To use more models, add credits on platform.openai.com."
);
}
#[test]
fn success_cell_renders_clipboard_failure() {
let cell = success_cell(
&CreatedApiKey {
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-1234567890".to_string(),
},
Err("clipboard unavailable".to_string()),
);
assert_eq!(
render_cell(&cell),
"• Created an API key for org-default / proj-default: sk-proj-...7890.\n Could not copy the key to your clipboard: clipboard unavailable. I also set OPENAI_API_KEY in this Codex session for future commands.\n\n To create more keys or monitor usage, go to platform.openai.com.\n\n You can start building with the OpenAI API with limited usage of gpt-5.4-nano. To use more models, add credits on platform.openai.com."
);
}
#[test]
fn continue_in_browser_message_mentions_manual_fallback() {
let cell = continue_in_browser_message(
"https://auth.example.test/oauth",
/*callback_port*/ 5000,
/*browser_opened*/ false,
);
assert_eq!(
render_cell(&cell),
"• Finish API key creation via your browser.\n\n Codex couldn't auto-open your browser, but the API key creation flow is still waiting.\n\n Open the following link to authenticate:\n\n https://auth.example.test/oauth\n\n Codex will display the new OPENAI_API_KEY here and copy it to your clipboard.\n\n On a remote or headless machine, forward localhost:5000 back to this Codex host before opening the link."
);
}
#[test]
fn mask_api_key_preserves_short_values() {
assert_eq!(mask_api_key("sk-short"), "sk-short");
}
#[test]
fn mask_api_key_redacts_middle() {
assert_eq!(mask_api_key("sk-proj-1234567890"), "sk-proj-...7890");
}
#[test]
fn mask_api_key_handles_unicode() {
assert_eq!(mask_api_key("abcdefgh🔥ijklmnop"), "abcdefgh...mnop");
}
fn render_cell(cell: &PlainHistoryCell) -> String {
cell.display_lines(/*width*/ 120)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>()
})
.collect::<Vec<String>>()
.join("\n")
}
}

View File

@@ -102,6 +102,7 @@ mod clipboard_paste;
mod clipboard_text;
mod collaboration_modes;
mod color;
mod create_api_key;
pub mod custom_terminal;
mod cwd_prompt;
mod debug_config;

View File

@@ -35,6 +35,7 @@ pub enum SlashCommand {
// Undo,
Diff,
Copy,
CreateApiKey,
Mention,
Status,
DebugConfig,
@@ -83,6 +84,7 @@ impl SlashCommand {
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
SlashCommand::CreateApiKey => "create an OpenAI API key for this Codex session",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Status => "show current session configuration and token usage",
@@ -155,6 +157,7 @@ impl SlashCommand {
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::CreateApiKey
| 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 create_api_key_command_metadata() {
assert_eq!(
SlashCommand::from_str("create-api-key"),
Ok(SlashCommand::CreateApiKey)
);
assert_eq!(SlashCommand::CreateApiKey.command(), "create-api-key");
assert!(!SlashCommand::CreateApiKey.supports_inline_args());
assert!(!SlashCommand::CreateApiKey.available_during_task());
}
}