From 904c751a4068e5a4f7005ef165f5d3afa756f420 Mon Sep 17 00:00:00 2001 From: Adrian <145513011+adrian-openai@users.noreply.github.com> Date: Mon, 20 Apr 2026 06:50:28 -0700 Subject: [PATCH] [codex] Use background agent task auth for backend calls (#18094) ## Summary Introduces a single background/control-plane agent task for ChatGPT backend requests that do not have a thread-scoped task, with `AuthManager` owning the default ChatGPT backend authorization decision. Callers now ask `AuthManager` for the default ChatGPT backend authorization header. `AuthManager` decides whether that is bearer or background AgentAssertion based on config/internal state, while low-level bootstrap paths can explicitly request bearer-only auth. This PR is stacked on PR4 and focuses on the shared background task auth plumbing plus the first tranche of backend/control-plane consumers. The remaining callsite wiring is split into PR4.2 to keep review size down. ## Stack - PR1: https://github.com/openai/codex/pull/17385 - add `features.use_agent_identity` - PR2: https://github.com/openai/codex/pull/17386 - register agent identities when enabled - PR3: https://github.com/openai/codex/pull/17387 - register agent tasks when enabled - PR3.1: https://github.com/openai/codex/pull/17978 - persist and prewarm registered tasks per thread - PR4: https://github.com/openai/codex/pull/17980 - use task-scoped `AgentAssertion` for downstream calls - PR4.1: this PR - introduce AuthManager-owned background/control-plane `AgentAssertion` auth - PR4.2: https://github.com/openai/codex/pull/18260 - use background task auth for additional backend/control-plane calls ## What Changed - add background task registration and assertion minting inside `codex-login` - persist `agent_identity.background_task_id` separately from per-session task state - make `BackgroundAgentTaskManager` private to `codex-login`; call sites do not instantiate or pass it around - teach `AuthManager` the ChatGPT backend base URL and feature-derived background auth mode from resolved config - expose bearer-only helpers for bootstrap/registration/refresh-style paths that must not use AgentAssertion - wire `AuthManager` default ChatGPT authorization through app listing, connector directory listing, remote plugins, MCP status/listing, analytics, and core-skills remote calls - preserve bearer fallback when the feature is disabled, the backend host is unsupported, or background task registration is not available ## Validation - `just fmt` - `cargo check -p codex-core -p codex-login -p codex-analytics -p codex-app-server -p codex-cloud-requirements -p codex-cloud-tasks -p codex-models-manager -p codex-chatgpt -p codex-model-provider -p codex-mcp -p codex-core-skills` - `cargo test -p codex-login agent_identity` - `cargo test -p codex-model-provider bearer_auth_provider` - `cargo test -p codex-core agent_assertion` - `cargo test -p codex-app-server remote_control` - `cargo test -p codex-cloud-requirements fetch_cloud_requirements` - `cargo test -p codex-models-manager manager::tests` - `cargo test -p codex-chatgpt` - `cargo test -p codex-cloud-tasks` - `just fix -p codex-core -p codex-login -p codex-analytics -p codex-app-server -p codex-cloud-requirements -p codex-cloud-tasks -p codex-models-manager -p codex-chatgpt -p codex-model-provider -p codex-mcp -p codex-core-skills` - `just fix -p codex-app-server` - `git diff --check` --- codex-rs/Cargo.lock | 1 + codex-rs/analytics/src/client.rs | 22 +- .../app-server/src/codex_message_processor.rs | 202 ++--- codex-rs/codex-mcp/src/lib.rs | 4 + codex-rs/codex-mcp/src/mcp/mod.rs | 92 +- codex-rs/core-skills/src/remote.rs | 31 +- codex-rs/core/src/agent_identity.rs | 3 + codex-rs/core/src/config/mod.rs | 13 +- codex-rs/core/src/connectors.rs | 53 +- codex-rs/core/src/mcp.rs | 20 +- codex-rs/core/src/session/handlers.rs | 14 +- codex-rs/core/src/session/mcp.rs | 15 +- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/session/session.rs | 14 +- codex-rs/core/src/session/tests.rs | 1 + codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/agent_identity.rs | 830 ++++++++++++++++++ codex-rs/login/src/auth/agent_assertion.rs | 1 + codex-rs/login/src/auth/auth_tests.rs | 1 + codex-rs/login/src/auth/manager.rs | 120 ++- codex-rs/login/src/auth/storage.rs | 2 + codex-rs/login/src/auth/storage_tests.rs | 1 + codex-rs/login/src/lib.rs | 3 + 23 files changed, 1297 insertions(+), 149 deletions(-) create mode 100644 codex-rs/login/src/agent_identity.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1946bea06..babf854a5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2385,6 +2385,7 @@ dependencies = [ "codex-terminal-detection", "codex-utils-template", "core_test_support", + "crypto_box", "ed25519-dalek", "keyring", "once_cell", diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 1a4b5defe..25ab40953 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -297,7 +297,7 @@ impl AnalyticsEventsClient { } async fn send_track_events( - auth_manager: &AuthManager, + auth_manager: &Arc, base_url: &str, events: Vec, ) { @@ -310,9 +310,11 @@ async fn send_track_events( if !auth.is_chatgpt_auth() { return; } - let access_token = match auth.get_token() { - Ok(token) => token, - Err(_) => return, + let Some(authorization_header_value) = auth_manager + .chatgpt_authorization_header_for_auth(&auth) + .await + else { + return; }; let Some(account_id) = auth.get_account_id() else { return; @@ -322,15 +324,17 @@ async fn send_track_events( let url = format!("{base_url}/codex/analytics-events/events"); let payload = TrackEventsRequest { events }; - let response = create_client() + let mut request = create_client() .post(&url) .timeout(ANALYTICS_EVENTS_TIMEOUT) - .bearer_auth(&access_token) + .header("authorization", authorization_header_value) .header("chatgpt-account-id", &account_id) .header("Content-Type", "application/json") - .json(&payload) - .send() - .await; + .json(&payload); + if auth.is_fedramp_account() { + request = request.header("X-OpenAI-Fedramp", "true"); + } + let response = request.send().await; match response { Ok(response) if response.status().is_success() => {} diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 0342cc84c..33c821bec 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -287,9 +287,9 @@ use codex_login::run_login_server; use codex_mcp::McpRuntimeEnvironment; use codex_mcp::McpServerStatusSnapshot; use codex_mcp::McpSnapshotDetail; -use codex_mcp::collect_mcp_server_status_snapshot_with_detail; +use codex_mcp::collect_mcp_server_status_snapshot_with_detail_and_authorization_header; use codex_mcp::discover_supported_scopes; -use codex_mcp::effective_mcp_servers; +use codex_mcp::effective_mcp_servers_with_authorization_header; use codex_mcp::resolve_oauth_scopes; use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig; use codex_protocol::ThreadId; @@ -5733,7 +5733,8 @@ impl CodexMessageProcessor { let mcp_config = config .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; - let auth = self.auth_manager.auth().await; + let auth_manager = Arc::clone(&self.auth_manager); + let auth = auth_manager.auth().await; let runtime_environment = match self.thread_manager.environment_manager().current().await { Ok(Some(environment)) => { // Status listing has no turn cwd. This fallback is used only @@ -5758,120 +5759,111 @@ impl CodexMessageProcessor { }; tokio::spawn(async move { - Self::list_mcp_server_status_task( - outgoing, - request, - params, - config, - mcp_config, - auth, + let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { + McpServerStatusDetail::Full => McpSnapshotDetail::Full, + McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, + }; + + let background_authorization_header_value = if let Some(auth) = auth.as_ref() { + auth_manager + .chatgpt_authorization_header_for_auth(auth) + .await + } else { + None + }; + let snapshot = collect_mcp_server_status_snapshot_with_detail_and_authorization_header( + &mcp_config, + auth.as_ref(), + request.request_id.to_string(), runtime_environment, + detail, + background_authorization_header_value.as_deref(), ) .await; - }); - } - async fn list_mcp_server_status_task( - outgoing: Arc, - request_id: ConnectionRequestId, - params: ListMcpServerStatusParams, - config: Config, - mcp_config: codex_mcp::McpConfig, - auth: Option, - runtime_environment: McpRuntimeEnvironment, - ) { - let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { - McpServerStatusDetail::Full => McpSnapshotDetail::Full, - McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, - }; + let effective_servers = effective_mcp_servers_with_authorization_header( + &mcp_config, + auth.as_ref(), + background_authorization_header_value.as_deref(), + ); + let McpServerStatusSnapshot { + tools_by_server, + resources, + resource_templates, + auth_statuses, + } = snapshot; - let snapshot = collect_mcp_server_status_snapshot_with_detail( - &mcp_config, - auth.as_ref(), - request_id.request_id.to_string(), - runtime_environment, - detail, - ) - .await; + let mut server_names: Vec = config + .mcp_servers + .keys() + .cloned() + // Include built-in/plugin MCP servers that are present in the + // effective runtime config even when they are not user-declared in + // `config.mcp_servers`. + .chain(effective_servers.keys().cloned()) + .chain(auth_statuses.keys().cloned()) + .chain(resources.keys().cloned()) + .chain(resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); - let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); - let McpServerStatusSnapshot { - tools_by_server, - resources, - resource_templates, - auth_statuses, - } = snapshot; - - let mut server_names: Vec = config - .mcp_servers - .keys() - .cloned() - // Include built-in/plugin MCP servers that are present in the - // effective runtime config even when they are not user-declared in - // `config.mcp_servers`. - .chain(effective_servers.keys().cloned()) - .chain(auth_statuses.keys().cloned()) - .chain(resources.keys().cloned()) - .chain(resource_templates.keys().cloned()) - .collect(); - server_names.sort(); - server_names.dedup(); - - let total = server_names.len(); - let limit = params.limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = limit.min(total); - let start = match params.cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }; - outgoing.send_error(request_id, error).await; - return; - } - }, - None => 0, - }; - - if start > total { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total MCP servers {total}"), - data: None, + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + outgoing.send_error(request, error).await; + return; + } + }, + None => 0, }; - outgoing.send_error(request_id, error).await; - return; - } - let end = start.saturating_add(effective_limit).min(total); + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total MCP servers {total}"), + data: None, + }; + outgoing.send_error(request, error).await; + return; + } - let data: Vec = server_names[start..end] - .iter() - .map(|name| McpServerStatus { - name: name.clone(), - tools: tools_by_server.get(name).cloned().unwrap_or_default(), - resources: resources.get(name).cloned().unwrap_or_default(), - resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), - auth_status: auth_statuses - .get(name) - .cloned() - .unwrap_or(CoreMcpAuthStatus::Unsupported) - .into(), - }) - .collect(); + let end = start.saturating_add(effective_limit).min(total); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServerStatus { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: resources.get(name).cloned().unwrap_or_default(), + resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), + auth_status: auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); - let response = ListMcpServerStatusResponse { data, next_cursor }; + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; - outgoing.send_response(request_id, response).await; + let response = ListMcpServerStatusResponse { data, next_cursor }; + + outgoing.send_response(request, response).await; + }); } async fn read_mcp_resource( diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 766465f05..5fb634d90 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -16,15 +16,18 @@ pub use mcp::ToolPluginProvenance; pub use mcp::canonical_mcp_server_key; pub use mcp::collect_mcp_server_status_snapshot; pub use mcp::collect_mcp_server_status_snapshot_with_detail; +pub use mcp::collect_mcp_server_status_snapshot_with_detail_and_authorization_header; pub use mcp::collect_mcp_snapshot; pub use mcp::collect_mcp_snapshot_from_manager; pub use mcp::collect_mcp_snapshot_from_manager_with_detail; pub use mcp::collect_mcp_snapshot_with_detail; +pub use mcp::collect_mcp_snapshot_with_detail_and_authorization_header; pub use mcp::collect_missing_mcp_dependencies; pub use mcp::compute_auth_statuses; pub use mcp::configured_mcp_servers; pub use mcp::discover_supported_scopes; pub use mcp::effective_mcp_servers; +pub use mcp::effective_mcp_servers_with_authorization_header; pub use mcp::group_tools_by_server; pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::oauth_login_support; @@ -34,6 +37,7 @@ pub use mcp::should_retry_without_scopes; pub use mcp::split_qualified_tool_name; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; +pub use mcp::with_codex_apps_mcp_with_authorization_header; pub use mcp_connection_manager::CodexAppsToolsCacheKey; pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 448259d5b..6a8d9c0a9 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -213,14 +213,25 @@ fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option { } } -fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option> { +fn codex_apps_mcp_http_headers( + auth: Option<&CodexAuth>, + authorization_header_value: Option<&str>, +) -> Option> { let mut headers = HashMap::new(); - if let Some(token) = codex_apps_mcp_bearer_token(auth) { + if let Some(authorization_header_value) = authorization_header_value { + headers.insert( + "Authorization".to_string(), + authorization_header_value.to_string(), + ); + } else if let Some(token) = codex_apps_mcp_bearer_token(auth) { headers.insert("Authorization".to_string(), format!("Bearer {token}")); } if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) { headers.insert("ChatGPT-Account-ID".to_string(), account_id); } + if auth.is_some_and(CodexAuth::is_fedramp_account) { + headers.insert("X-OpenAI-Fedramp".to_string(), "true".to_string()); + } if headers.is_empty() { None } else { @@ -254,12 +265,16 @@ pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) } -fn codex_apps_mcp_server_config(config: &McpConfig, auth: Option<&CodexAuth>) -> McpServerConfig { +fn codex_apps_mcp_server_config( + config: &McpConfig, + auth: Option<&CodexAuth>, + authorization_header_value: Option<&str>, +) -> McpServerConfig { let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var(); let http_headers = if bearer_token_env_var.is_some() { None } else { - codex_apps_mcp_http_headers(auth) + codex_apps_mcp_http_headers(auth, authorization_header_value) }; let url = codex_apps_mcp_url(config); @@ -287,14 +302,25 @@ fn codex_apps_mcp_server_config(config: &McpConfig, auth: Option<&CodexAuth>) -> } pub fn with_codex_apps_mcp( + servers: HashMap, + auth: Option<&CodexAuth>, + config: &McpConfig, +) -> HashMap { + with_codex_apps_mcp_with_authorization_header( + servers, auth, config, /*authorization_header_value*/ None, + ) +} + +pub fn with_codex_apps_mcp_with_authorization_header( mut servers: HashMap, auth: Option<&CodexAuth>, config: &McpConfig, + authorization_header_value: Option<&str>, ) -> HashMap { if config.apps_enabled && auth.is_some_and(CodexAuth::is_chatgpt_auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), - codex_apps_mcp_server_config(config, auth), + codex_apps_mcp_server_config(config, auth, authorization_header_value), ); } else { servers.remove(CODEX_APPS_MCP_SERVER_NAME); @@ -309,9 +335,19 @@ pub fn configured_mcp_servers(config: &McpConfig) -> HashMap, +) -> HashMap { + effective_mcp_servers_with_authorization_header( + config, auth, /*authorization_header_value*/ None, + ) +} + +pub fn effective_mcp_servers_with_authorization_header( + config: &McpConfig, + auth: Option<&CodexAuth>, + authorization_header_value: Option<&str>, ) -> HashMap { let servers = configured_mcp_servers(config); - with_codex_apps_mcp(servers, auth, config) + with_codex_apps_mcp_with_authorization_header(servers, auth, config, authorization_header_value) } pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { @@ -341,7 +377,27 @@ pub async fn collect_mcp_snapshot_with_detail( runtime_environment: McpRuntimeEnvironment, detail: McpSnapshotDetail, ) -> McpListToolsResponseEvent { - let mcp_servers = effective_mcp_servers(config, auth); + collect_mcp_snapshot_with_detail_and_authorization_header( + config, + auth, + submit_id, + runtime_environment, + detail, + /*authorization_header_value*/ None, + ) + .await +} + +pub async fn collect_mcp_snapshot_with_detail_and_authorization_header( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, + runtime_environment: McpRuntimeEnvironment, + detail: McpSnapshotDetail, + authorization_header_value: Option<&str>, +) -> McpListToolsResponseEvent { + let mcp_servers = + effective_mcp_servers_with_authorization_header(config, auth, authorization_header_value); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpListToolsResponseEvent { @@ -416,7 +472,27 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment: McpRuntimeEnvironment, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { - let mcp_servers = effective_mcp_servers(config, auth); + collect_mcp_server_status_snapshot_with_detail_and_authorization_header( + config, + auth, + submit_id, + runtime_environment, + detail, + /*authorization_header_value*/ None, + ) + .await +} + +pub async fn collect_mcp_server_status_snapshot_with_detail_and_authorization_header( + config: &McpConfig, + auth: Option<&CodexAuth>, + submit_id: String, + runtime_environment: McpRuntimeEnvironment, + detail: McpSnapshotDetail, + authorization_header_value: Option<&str>, +) -> McpServerStatusSnapshot { + let mcp_servers = + effective_mcp_servers_with_authorization_header(config, auth, authorization_header_value); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { diff --git a/codex-rs/core-skills/src/remote.rs b/codex-rs/core-skills/src/remote.rs index 2dc620b86..c85c78ff8 100644 --- a/codex-rs/core-skills/src/remote.rs +++ b/codex-rs/core-skills/src/remote.rs @@ -6,7 +6,9 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use codex_login::BackgroundAgentTaskAuthMode; use codex_login::CodexAuth; +use codex_login::cached_background_agent_task_authorization_header_value; use codex_login::default_client::build_reqwest_client; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); @@ -112,13 +114,15 @@ pub async fn list_remote_skills( .get(&url) .timeout(REMOTE_SKILLS_API_TIMEOUT) .query(&query_params); - let token = auth - .get_token() + let authorization_header_value = authorization_header_value_for_auth(auth) .context("Failed to read auth token for remote skills")?; - request = request.bearer_auth(token); + request = request.header("authorization", authorization_header_value); if let Some(account_id) = auth.get_account_id() { request = request.header("chatgpt-account-id", account_id); } + if auth.is_fedramp_account() { + request = request.header("X-OpenAI-Fedramp", "true"); + } let response = request .send() .await @@ -157,13 +161,15 @@ pub async fn export_remote_skill( let url = format!("{base_url}/hazelnuts/{skill_id}/export"); let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); - let token = auth - .get_token() + let authorization_header_value = authorization_header_value_for_auth(auth) .context("Failed to read auth token for remote skills")?; - request = request.bearer_auth(token); + request = request.header("authorization", authorization_header_value); if let Some(account_id) = auth.get_account_id() { request = request.header("chatgpt-account-id", account_id); } + if auth.is_fedramp_account() { + request = request.header("X-OpenAI-Fedramp", "true"); + } let response = request .send() @@ -201,6 +207,19 @@ pub async fn export_remote_skill( }) } +fn authorization_header_value_for_auth(auth: &CodexAuth) -> std::io::Result { + if let Ok(Some(authorization_header_value)) = + cached_background_agent_task_authorization_header_value( + auth, + BackgroundAgentTaskAuthMode::Disabled, + ) + { + Ok(authorization_header_value) + } else { + auth.get_token().map(|token| format!("Bearer {token}")) + } +} + fn safe_join(base: &Path, name: &str) -> Result { let path = Path::new(name); for component in path.components() { diff --git a/codex-rs/core/src/agent_identity.rs b/codex-rs/core/src/agent_identity.rs index a77fd8fa4..e2ee5e6b3 100644 --- a/codex-rs/core/src/agent_identity.rs +++ b/codex-rs/core/src/agent_identity.rs @@ -408,6 +408,7 @@ impl StoredAgentIdentity { agent_runtime_id: self.agent_runtime_id.clone(), agent_private_key: self.private_key_pkcs8_base64.clone(), registered_at: self.registered_at.clone(), + background_task_id: None, } } @@ -668,6 +669,7 @@ mod tests { agent_runtime_id: "agent_invalid".to_string(), agent_private_key: "not-valid-base64".to_string(), registered_at: "2026-01-01T00:00:00Z".to_string(), + background_task_id: None, }) .expect("seed invalid identity"); @@ -707,6 +709,7 @@ mod tests { agent_runtime_id: "agent_old".to_string(), agent_private_key: stale_key.private_key_pkcs8_base64, registered_at: "2026-01-01T00:00:00Z".to_string(), + background_task_id: None, }) .expect("seed stale identity"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cd55e7e35..2fef40a42 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -49,7 +49,7 @@ use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; -use codex_features::Feature; +pub use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::FeatureToml; @@ -58,6 +58,7 @@ use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; +use codex_login::BackgroundAgentTaskAuthMode; use codex_mcp::McpConfig; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -629,6 +630,16 @@ impl AuthManagerConfig for Config { fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id.clone() } + + fn chatgpt_base_url(&self) -> Option { + Some(self.chatgpt_base_url.clone()) + } + + fn background_agent_task_auth_mode(&self) -> BackgroundAgentTaskAuthMode { + BackgroundAgentTaskAuthMode::from_feature_enabled( + self.features.enabled(Feature::UseAgentIdentity), + ) + } } #[derive(Debug, Clone, Default)] diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 933ce0ac7..965521ae3 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -43,7 +43,7 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; -use codex_mcp::with_codex_apps_mcp; +use codex_mcp::with_codex_apps_mcp_with_authorization_header; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); @@ -220,8 +220,20 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( }); } + let background_authorization_header_value = if let Some(auth) = auth.as_ref() { + auth_manager + .chatgpt_authorization_header_for_auth(auth) + .await + } else { + None + }; let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await; - let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let mcp_servers = with_codex_apps_mcp_with_authorization_header( + HashMap::new(), + auth.as_ref(), + &mcp_config, + background_authorization_header_value.as_deref(), + ); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -423,6 +435,18 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( }; let access_token = token_data.access_token.clone(); let account_id = account_id.to_string(); + let is_fedramp_account = token_data.id_token.is_fedramp_account(); + let authorization_header_value = { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false); + match auth { + Some(auth) if auth.is_chatgpt_auth() => auth_manager + .chatgpt_authorization_header_for_auth(auth) + .await + .unwrap_or_else(|| format!("Bearer {access_token}")), + _ => format!("Bearer {access_token}"), + } + }; let is_workspace_account = token_data.id_token.is_workspace_account(); let cache_key = AllConnectorsCacheKey::new( config.chatgpt_base_url.clone(), @@ -436,14 +460,15 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( is_workspace_account, /*force_refetch*/ false, |path| { - let access_token = access_token.clone(); + let authorization_header_value = authorization_header_value.clone(); let account_id = account_id.clone(); async move { - chatgpt_get_request_with_token::( + chatgpt_get_request_with_authorization_header::( config, path, - access_token.as_str(), + authorization_header_value.as_str(), account_id.as_str(), + is_fedramp_account, ) .await } @@ -452,23 +477,25 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( .await } -async fn chatgpt_get_request_with_token( +async fn chatgpt_get_request_with_authorization_header( config: &Config, path: String, - access_token: &str, + authorization_header_value: &str, account_id: &str, + is_fedramp_account: bool, ) -> anyhow::Result { let client = create_client(); let url = format!("{}{}", config.chatgpt_base_url, path); - let response = client + let mut request = client .get(&url) - .bearer_auth(access_token) + .header("authorization", authorization_header_value) .header("chatgpt-account-id", account_id) .header("Content-Type", "application/json") - .timeout(DIRECTORY_CONNECTORS_TIMEOUT) - .send() - .await - .context("failed to send request")?; + .timeout(DIRECTORY_CONNECTORS_TIMEOUT); + if is_fedramp_account { + request = request.header("X-OpenAI-Fedramp", "true"); + } + let response = request.send().await.context("failed to send request")?; if response.status().is_success() { response diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index 0d4c26991..715a1300d 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -7,7 +7,7 @@ use codex_config::McpServerConfig; use codex_login::CodexAuth; use codex_mcp::ToolPluginProvenance; use codex_mcp::configured_mcp_servers; -use codex_mcp::effective_mcp_servers; +use codex_mcp::effective_mcp_servers_with_authorization_header; use codex_mcp::tool_plugin_provenance as collect_tool_plugin_provenance; #[derive(Clone)] @@ -29,9 +29,25 @@ impl McpManager { &self, config: &Config, auth: Option<&CodexAuth>, + ) -> HashMap { + self.effective_servers_with_authorization_header( + config, auth, /*authorization_header_value*/ None, + ) + .await + } + + pub async fn effective_servers_with_authorization_header( + &self, + config: &Config, + auth: Option<&CodexAuth>, + authorization_header_value: Option<&str>, ) -> HashMap { let mcp_config = config.to_mcp_config(self.plugins_manager.as_ref()).await; - effective_mcp_servers(&mcp_config, auth) + effective_mcp_servers_with_authorization_header( + &mcp_config, + auth, + authorization_header_value, + ) } pub async fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 72cfc5782..b45b6fc10 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -471,10 +471,22 @@ pub async fn reload_user_config(sess: &Arc) { pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; let auth = sess.services.auth_manager.auth().await; + let background_authorization_header_value = if let Some(auth) = auth.as_ref() { + sess.services + .auth_manager + .chatgpt_authorization_header_for_auth(auth) + .await + } else { + None + }; let mcp_servers = sess .services .mcp_manager - .effective_servers(config, auth.as_ref()) + .effective_servers_with_authorization_header( + config, + auth.as_ref(), + background_authorization_header_value.as_deref(), + ) .await; let snapshot = collect_mcp_snapshot_from_manager( &mcp_connection_manager, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 8b7935a50..d15ca2cae 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -190,7 +190,20 @@ impl Session { .mcp_manager .tool_plugin_provenance(config.as_ref()) .await; - let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); + let background_authorization_header_value = if let Some(auth) = auth.as_ref() { + self.services + .auth_manager + .chatgpt_authorization_header_for_auth(auth) + .await + } else { + None + }; + let mcp_servers = with_codex_apps_mcp_with_authorization_header( + mcp_servers, + auth.as_ref(), + &mcp_config, + background_authorization_header_value.as_deref(), + ); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await; { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1b3815c7f..80b6ac95d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -293,7 +293,7 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_git_utils::get_git_repo_root; use codex_mcp::compute_auth_statuses; -use codex_mcp::with_codex_apps_mcp; +use codex_mcp::with_codex_apps_mcp_with_authorization_header; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b3e680241..5cc0d2df7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -332,8 +332,20 @@ impl Session { let mcp_manager_for_mcp = Arc::clone(&mcp_manager); let auth_and_mcp_fut = async move { let auth = auth_manager_clone.auth().await; + let authorization_header_value = match auth.as_ref() { + Some(auth) => { + auth_manager_clone + .chatgpt_authorization_header_for_auth(auth) + .await + } + None => None, + }; let mcp_servers = mcp_manager_for_mcp - .effective_servers(&config_for_mcp, auth.as_ref()) + .effective_servers_with_authorization_header( + &config_for_mcp, + auth.as_ref(), + authorization_header_value.as_deref(), + ) .await; let auth_statuses = compute_auth_statuses( mcp_servers.iter(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f457dec5e..acde30560 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4467,6 +4467,7 @@ fn seed_stored_identity( agent_runtime_id: stored_identity.agent_runtime_id.clone(), agent_private_key: stored_identity.private_key_pkcs8_base64.clone(), registered_at: stored_identity.registered_at.clone(), + background_task_id: None, }) .expect("store identity"); diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 16d183602..c56c891e1 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -21,6 +21,7 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-template = { workspace = true } +crypto_box = { workspace = true } ed25519-dalek = { workspace = true } once_cell = { workspace = true } os_info = { workspace = true } diff --git a/codex-rs/login/src/agent_identity.rs b/codex-rs/login/src/agent_identity.rs new file mode 100644 index 000000000..751dd4538 --- /dev/null +++ b/codex-rs/login/src/agent_identity.rs @@ -0,0 +1,830 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::SecondsFormat; +use chrono::Utc; +use codex_protocol::protocol::SessionSource; +use crypto_box::SecretKey as Curve25519SecretKey; +use ed25519_dalek::Signer as _; +use ed25519_dalek::SigningKey; +use ed25519_dalek::VerifyingKey; +use ed25519_dalek::pkcs8::DecodePrivateKey; +use ed25519_dalek::pkcs8::EncodePrivateKey; +use rand::TryRngCore; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest as _; +use sha2::Sha512; +use tokio::sync::Mutex; +use tracing::debug; +use tracing::info; +use tracing::warn; + +use crate::AgentIdentityAuthRecord; +use crate::AuthManager; +use crate::CodexAuth; +use crate::default_client::create_client; + +const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); +const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15); +const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Clone)] +pub(crate) struct BackgroundAgentTaskManager { + auth_manager: Arc, + chatgpt_base_url: String, + auth_mode: BackgroundAgentTaskAuthMode, + abom: AgentBillOfMaterials, + ensure_lock: Arc>, +} + +impl std::fmt::Debug for BackgroundAgentTaskManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BackgroundAgentTaskManager") + .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("auth_mode", &self.auth_mode) + .field("abom", &self.abom) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BackgroundAgentTaskAuthMode { + Enabled, + #[default] + Disabled, +} + +impl BackgroundAgentTaskAuthMode { + pub fn from_feature_enabled(enabled: bool) -> Self { + if enabled { + Self::Enabled + } else { + Self::Disabled + } + } + + fn is_enabled(self) -> bool { + matches!(self, Self::Enabled) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct StoredAgentIdentity { + binding_id: String, + chatgpt_account_id: String, + chatgpt_user_id: Option, + agent_runtime_id: String, + private_key_pkcs8_base64: String, + public_key_ssh: String, + registered_at: String, + background_task_id: Option, + abom: AgentBillOfMaterials, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +struct AgentBillOfMaterials { + agent_version: String, + agent_harness_id: String, + running_location: String, +} + +#[derive(Debug, Serialize)] +struct RegisterAgentRequest { + abom: AgentBillOfMaterials, + agent_public_key: String, + capabilities: Vec, +} + +#[derive(Debug, Deserialize)] +struct RegisterAgentResponse { + agent_runtime_id: String, +} + +#[derive(Debug, Serialize)] +struct RegisterTaskRequest { + signature: String, + timestamp: String, +} + +#[derive(Debug, Deserialize)] +struct RegisterTaskResponse { + encrypted_task_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentIdentityBinding { + binding_id: String, + chatgpt_account_id: String, + chatgpt_user_id: Option, + access_token: String, +} + +struct GeneratedAgentKeyMaterial { + private_key_pkcs8_base64: String, + public_key_ssh: String, +} + +impl BackgroundAgentTaskManager { + #[cfg(test)] + pub(crate) fn new( + auth_manager: Arc, + chatgpt_base_url: String, + session_source: SessionSource, + ) -> Self { + Self::new_with_auth_mode( + auth_manager, + chatgpt_base_url, + session_source, + BackgroundAgentTaskAuthMode::Disabled, + ) + } + + pub(crate) fn new_with_auth_mode( + auth_manager: Arc, + chatgpt_base_url: String, + session_source: SessionSource, + auth_mode: BackgroundAgentTaskAuthMode, + ) -> Self { + Self { + auth_manager, + chatgpt_base_url: normalize_chatgpt_base_url(&chatgpt_base_url), + auth_mode, + abom: build_abom(session_source), + ensure_lock: Arc::new(Mutex::new(())), + } + } + + pub(crate) async fn authorization_header_value_for_auth( + &self, + auth: &CodexAuth, + ) -> Result> { + if !self.auth_mode.is_enabled() { + debug!("skipping background agent task auth because agent identity is disabled"); + return Ok(None); + } + + if !supports_background_agent_task_auth(&self.chatgpt_base_url) { + debug!( + chatgpt_base_url = %self.chatgpt_base_url, + "skipping background agent task auth for unsupported backend host" + ); + return Ok(None); + } + + let Some(binding) = + AgentIdentityBinding::from_auth(auth, self.auth_manager.forced_chatgpt_workspace_id()) + else { + debug!("skipping background agent task auth because ChatGPT auth is unavailable"); + return Ok(None); + }; + + let _guard = self.ensure_lock.lock().await; + let mut stored_identity = self + .ensure_registered_identity_for_binding(auth, &binding) + .await?; + let background_task_id = match stored_identity.background_task_id.clone() { + Some(background_task_id) => background_task_id, + _ => { + let background_task_id = self + .register_background_task_for_identity(&binding, &stored_identity) + .await?; + stored_identity.background_task_id = Some(background_task_id.clone()); + self.store_identity(auth, &stored_identity)?; + background_task_id + } + }; + + Ok(Some(authorization_header_for_task( + &stored_identity, + &background_task_id, + )?)) + } + + pub(crate) async fn authorization_header_value_or_bearer( + &self, + auth: &CodexAuth, + ) -> Option { + match self.authorization_header_value_for_auth(auth).await { + Ok(Some(authorization_header_value)) => Some(authorization_header_value), + Ok(None) => auth + .get_token() + .ok() + .filter(|token| !token.is_empty()) + .map(|token| format!("Bearer {token}")), + Err(error) => { + warn!( + error = %error, + "falling back to bearer authorization because background agent task auth failed" + ); + auth.get_token() + .ok() + .filter(|token| !token.is_empty()) + .map(|token| format!("Bearer {token}")) + } + } + } + + async fn ensure_registered_identity_for_binding( + &self, + auth: &CodexAuth, + binding: &AgentIdentityBinding, + ) -> Result { + if let Some(stored_identity) = self.load_stored_identity(auth, binding)? { + return Ok(stored_identity); + } + + let stored_identity = self.register_agent_identity(binding).await?; + self.store_identity(auth, &stored_identity)?; + Ok(stored_identity) + } + + async fn register_agent_identity( + &self, + binding: &AgentIdentityBinding, + ) -> Result { + let key_material = generate_agent_key_material()?; + let request_body = RegisterAgentRequest { + abom: self.abom.clone(), + agent_public_key: key_material.public_key_ssh.clone(), + capabilities: Vec::new(), + }; + + let url = agent_registration_url(&self.chatgpt_base_url); + let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?; + let client = create_client(); + let response = client + .post(&url) + .header("X-OpenAI-Authorization", human_biscuit) + .json(&request_body) + .timeout(AGENT_REGISTRATION_TIMEOUT) + .send() + .await + .with_context(|| { + format!("failed to send agent identity registration request to {url}") + })?; + + if response.status().is_success() { + let response_body = response + .json::() + .await + .with_context(|| format!("failed to parse agent identity response from {url}"))?; + let stored_identity = StoredAgentIdentity { + binding_id: binding.binding_id.clone(), + chatgpt_account_id: binding.chatgpt_account_id.clone(), + chatgpt_user_id: binding.chatgpt_user_id.clone(), + agent_runtime_id: response_body.agent_runtime_id, + private_key_pkcs8_base64: key_material.private_key_pkcs8_base64, + public_key_ssh: key_material.public_key_ssh, + registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + background_task_id: None, + abom: self.abom.clone(), + }; + info!( + agent_runtime_id = %stored_identity.agent_runtime_id, + binding_id = %binding.binding_id, + "registered background agent identity" + ); + return Ok(stored_identity); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("agent identity registration failed with status {status} from {url}: {body}") + } + + async fn register_background_task_for_identity( + &self, + binding: &AgentIdentityBinding, + stored_identity: &StoredAgentIdentity, + ) -> Result { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let request_body = RegisterTaskRequest { + signature: sign_task_registration_payload(stored_identity, ×tamp)?, + timestamp, + }; + + let client = create_client(); + let url = + agent_task_registration_url(&self.chatgpt_base_url, &stored_identity.agent_runtime_id); + let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?; + let response = client + .post(&url) + .header("X-OpenAI-Authorization", human_biscuit) + .json(&request_body) + .timeout(AGENT_TASK_REGISTRATION_TIMEOUT) + .send() + .await + .with_context(|| format!("failed to send background agent task request to {url}"))?; + + if response.status().is_success() { + let response_body = response + .json::() + .await + .with_context(|| format!("failed to parse background task response from {url}"))?; + let background_task_id = + decrypt_task_id_response(stored_identity, &response_body.encrypted_task_id)?; + info!( + agent_runtime_id = %stored_identity.agent_runtime_id, + task_id = %background_task_id, + "registered background agent task" + ); + return Ok(background_task_id); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!( + "background agent task registration failed with status {status} from {url}: {body}" + ) + } + + async fn mint_human_biscuit( + &self, + binding: &AgentIdentityBinding, + target_method: &str, + target_url: &str, + ) -> Result { + let url = agent_identity_biscuit_url(&self.chatgpt_base_url); + let request_id = agent_identity_request_id()?; + let client = create_client(); + let response = client + .get(&url) + .bearer_auth(&binding.access_token) + .header("X-Request-Id", request_id.clone()) + .header("X-Original-Method", target_method) + .header("X-Original-Url", target_url) + .timeout(AGENT_IDENTITY_BISCUIT_TIMEOUT) + .send() + .await + .with_context(|| format!("failed to send agent identity biscuit request to {url}"))?; + + if response.status().is_success() { + let human_biscuit = response + .headers() + .get("x-openai-authorization") + .context("agent identity biscuit response did not include x-openai-authorization")? + .to_str() + .context("agent identity biscuit response header was not valid UTF-8")? + .to_string(); + info!( + request_id = %request_id, + "minted human biscuit for background agent task" + ); + return Ok(human_biscuit); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!( + "agent identity biscuit minting failed with status {status} from {url}: {body}" + ) + } + + fn load_stored_identity( + &self, + auth: &CodexAuth, + binding: &AgentIdentityBinding, + ) -> Result> { + let Some(record) = auth.get_agent_identity(&binding.chatgpt_account_id) else { + return Ok(None); + }; + + let stored_identity = + match StoredAgentIdentity::from_auth_record(binding, record, self.abom.clone()) { + Ok(stored_identity) => stored_identity, + Err(error) => { + warn!( + binding_id = %binding.binding_id, + error = %error, + "stored agent identity is invalid; deleting cached value" + ); + auth.remove_agent_identity()?; + return Ok(None); + } + }; + + if !stored_identity.matches_binding(binding) { + warn!( + binding_id = %binding.binding_id, + "stored agent identity binding no longer matches current auth; deleting cached value" + ); + auth.remove_agent_identity()?; + return Ok(None); + } + + if let Err(error) = stored_identity.validate_key_material() { + warn!( + agent_runtime_id = %stored_identity.agent_runtime_id, + binding_id = %binding.binding_id, + error = %error, + "stored agent identity key material is invalid; deleting cached value" + ); + auth.remove_agent_identity()?; + return Ok(None); + } + + Ok(Some(stored_identity)) + } + + fn store_identity( + &self, + auth: &CodexAuth, + stored_identity: &StoredAgentIdentity, + ) -> Result<()> { + auth.set_agent_identity(stored_identity.to_auth_record())?; + Ok(()) + } +} + +pub fn cached_background_agent_task_authorization_header_value( + auth: &CodexAuth, + auth_mode: BackgroundAgentTaskAuthMode, +) -> Result> { + if !auth_mode.is_enabled() { + return Ok(None); + } + + let Some(binding) = AgentIdentityBinding::from_auth(auth, /*forced_workspace_id*/ None) else { + return Ok(None); + }; + let Some(record) = auth.get_agent_identity(&binding.chatgpt_account_id) else { + return Ok(None); + }; + let stored_identity = + StoredAgentIdentity::from_auth_record(&binding, record, build_abom(SessionSource::Cli))?; + if !stored_identity.matches_binding(&binding) { + return Ok(None); + } + stored_identity.validate_key_material()?; + let Some(background_task_id) = stored_identity.background_task_id.as_ref() else { + return Ok(None); + }; + authorization_header_for_task(&stored_identity, background_task_id).map(Some) +} + +impl StoredAgentIdentity { + fn from_auth_record( + binding: &AgentIdentityBinding, + record: AgentIdentityAuthRecord, + abom: AgentBillOfMaterials, + ) -> Result { + if record.workspace_id != binding.chatgpt_account_id { + anyhow::bail!( + "stored agent identity workspace {:?} does not match current workspace {:?}", + record.workspace_id, + binding.chatgpt_account_id + ); + } + let signing_key = signing_key_from_private_key_pkcs8_base64(&record.agent_private_key)?; + Ok(Self { + binding_id: binding.binding_id.clone(), + chatgpt_account_id: binding.chatgpt_account_id.clone(), + chatgpt_user_id: record.chatgpt_user_id, + agent_runtime_id: record.agent_runtime_id.clone(), + private_key_pkcs8_base64: record.agent_private_key, + public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()), + registered_at: record.registered_at, + background_task_id: record.background_task_id, + abom, + }) + } + + fn to_auth_record(&self) -> AgentIdentityAuthRecord { + AgentIdentityAuthRecord { + workspace_id: self.chatgpt_account_id.clone(), + chatgpt_user_id: self.chatgpt_user_id.clone(), + agent_runtime_id: self.agent_runtime_id.clone(), + agent_private_key: self.private_key_pkcs8_base64.clone(), + registered_at: self.registered_at.clone(), + background_task_id: self.background_task_id.clone(), + } + } + + fn matches_binding(&self, binding: &AgentIdentityBinding) -> bool { + binding.matches_parts( + &self.binding_id, + &self.chatgpt_account_id, + self.chatgpt_user_id.as_deref(), + ) + } + + fn validate_key_material(&self) -> Result<()> { + let signing_key = self.signing_key()?; + let derived_public_key = encode_ssh_ed25519_public_key(&signing_key.verifying_key()); + anyhow::ensure!( + self.public_key_ssh == derived_public_key, + "stored public key does not match the private key" + ); + Ok(()) + } + + fn signing_key(&self) -> Result { + signing_key_from_private_key_pkcs8_base64(&self.private_key_pkcs8_base64) + } +} + +impl AgentIdentityBinding { + fn matches_parts( + &self, + binding_id: &str, + chatgpt_account_id: &str, + chatgpt_user_id: Option<&str>, + ) -> bool { + binding_id == self.binding_id + && chatgpt_account_id == self.chatgpt_account_id + && match self.chatgpt_user_id.as_deref() { + Some(expected_user_id) => chatgpt_user_id == Some(expected_user_id), + None => true, + } + } + + fn from_auth(auth: &CodexAuth, forced_workspace_id: Option) -> Option { + if !auth.is_chatgpt_auth() { + return None; + } + + let token_data = auth.get_token_data().ok()?; + let resolved_account_id = + forced_workspace_id + .filter(|value| !value.is_empty()) + .or(token_data + .account_id + .clone() + .filter(|value| !value.is_empty()))?; + + Some(Self { + binding_id: format!("chatgpt-account-{resolved_account_id}"), + chatgpt_account_id: resolved_account_id, + chatgpt_user_id: token_data + .id_token + .chatgpt_user_id + .filter(|value| !value.is_empty()), + access_token: token_data.access_token, + }) + } +} + +fn authorization_header_for_task( + stored_identity: &StoredAgentIdentity, + background_task_id: &str, +) -> Result { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let signature = sign_agent_assertion_payload(stored_identity, background_task_id, ×tamp)?; + let payload = serde_json::to_vec(&BTreeMap::from([ + ( + "agent_runtime_id", + stored_identity.agent_runtime_id.as_str(), + ), + ("signature", signature.as_str()), + ("task_id", background_task_id), + ("timestamp", timestamp.as_str()), + ])) + .context("failed to serialize agent assertion envelope")?; + Ok(format!( + "AgentAssertion {}", + URL_SAFE_NO_PAD.encode(payload) + )) +} + +fn sign_agent_assertion_payload( + stored_identity: &StoredAgentIdentity, + background_task_id: &str, + timestamp: &str, +) -> Result { + let signing_key = stored_identity.signing_key()?; + let payload = format!( + "{}:{background_task_id}:{timestamp}", + stored_identity.agent_runtime_id + ); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +fn sign_task_registration_payload( + stored_identity: &StoredAgentIdentity, + timestamp: &str, +) -> Result { + let signing_key = stored_identity.signing_key()?; + let payload = format!("{}:{timestamp}", stored_identity.agent_runtime_id); + Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) +} + +fn decrypt_task_id_response( + stored_identity: &StoredAgentIdentity, + encrypted_task_id: &str, +) -> Result { + let signing_key = stored_identity.signing_key()?; + let ciphertext = BASE64_STANDARD + .decode(encrypted_task_id) + .context("encrypted task id is not valid base64")?; + let plaintext = curve25519_secret_key_from_signing_key(&signing_key) + .unseal(&ciphertext) + .map_err(|_| anyhow::anyhow!("failed to decrypt encrypted task id"))?; + String::from_utf8(plaintext).context("decrypted task id is not valid UTF-8") +} + +fn curve25519_secret_key_from_signing_key(signing_key: &SigningKey) -> Curve25519SecretKey { + let digest = Sha512::digest(signing_key.to_bytes()); + let mut secret_key = [0u8; 32]; + secret_key.copy_from_slice(&digest[..32]); + secret_key[0] &= 248; + secret_key[31] &= 127; + secret_key[31] |= 64; + Curve25519SecretKey::from(secret_key) +} + +fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials { + AgentBillOfMaterials { + agent_version: env!("CARGO_PKG_VERSION").to_string(), + agent_harness_id: match &session_source { + SessionSource::VSCode => "codex-app".to_string(), + SessionSource::Cli + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::SubAgent(_) + | SessionSource::Unknown => "codex-cli".to_string(), + }, + running_location: format!("{}-{}", session_source, std::env::consts::OS), + } +} + +fn generate_agent_key_material() -> Result { + let mut secret_key_bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut secret_key_bytes) + .context("failed to generate agent identity private key bytes")?; + let signing_key = SigningKey::from_bytes(&secret_key_bytes); + let private_key_pkcs8 = signing_key + .to_pkcs8_der() + .context("failed to encode agent identity private key as PKCS#8")?; + + Ok(GeneratedAgentKeyMaterial { + private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key_pkcs8.as_bytes()), + public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()), + }) +} + +fn encode_ssh_ed25519_public_key(verifying_key: &VerifyingKey) -> String { + let mut blob = Vec::with_capacity(4 + 11 + 4 + 32); + append_ssh_string(&mut blob, b"ssh-ed25519"); + append_ssh_string(&mut blob, verifying_key.as_bytes()); + format!("ssh-ed25519 {}", BASE64_STANDARD.encode(blob)) +} + +fn append_ssh_string(buf: &mut Vec, value: &[u8]) { + buf.extend_from_slice(&(value.len() as u32).to_be_bytes()); + buf.extend_from_slice(value); +} + +fn signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64: &str) -> Result { + let private_key = BASE64_STANDARD + .decode(private_key_pkcs8_base64) + .context("stored agent identity private key is not valid base64")?; + SigningKey::from_pkcs8_der(&private_key) + .context("stored agent identity private key is not valid PKCS#8") +} + +fn agent_registration_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/register") +} + +fn agent_task_registration_url(chatgpt_base_url: &str, agent_runtime_id: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/v1/agent/{agent_runtime_id}/task/register") +} + +fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String { + let trimmed = chatgpt_base_url.trim_end_matches('/'); + format!("{trimmed}/authenticate_app_v2") +} + +fn agent_identity_request_id() -> Result { + let mut request_id_bytes = [0u8; 16]; + OsRng + .try_fill_bytes(&mut request_id_bytes) + .context("failed to generate agent identity request id")?; + Ok(format!( + "codex-agent-identity-{}", + URL_SAFE_NO_PAD.encode(request_id_bytes) + )) +} + +fn normalize_chatgpt_base_url(chatgpt_base_url: &str) -> String { + let mut base_url = chatgpt_base_url.trim_end_matches('/').to_string(); + for suffix in [ + "/wham/remote/control/server/enroll", + "/wham/remote/control/server", + ] { + if let Some(stripped) = base_url.strip_suffix(suffix) { + base_url = stripped.to_string(); + break; + } + } + if (base_url.starts_with("https://chatgpt.com") + || base_url.starts_with("https://chat.openai.com")) + && !base_url.contains("/backend-api") + { + base_url = format!("{base_url}/backend-api"); + } + if let Some(stripped) = base_url.strip_suffix("/codex") { + stripped.to_string() + } else { + base_url + } +} + +fn supports_background_agent_task_auth(chatgpt_base_url: &str) -> bool { + let Ok(url) = url::Url::parse(chatgpt_base_url) else { + return false; + }; + let Some(host) = url.host_str() else { + return false; + }; + host == "chatgpt.com" + || host == "chat.openai.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn disabled_background_agent_task_auth_returns_none_for_supported_host() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let auth_manager = AuthManager::from_auth_for_testing(auth.clone()); + let manager = BackgroundAgentTaskManager::new_with_auth_mode( + auth_manager, + "https://chatgpt.com/backend-api".to_string(), + SessionSource::Cli, + BackgroundAgentTaskAuthMode::Disabled, + ); + + let authorization_header_value = manager + .authorization_header_value_for_auth(&auth) + .await + .expect("disabled manager should not fail"); + + assert_eq!(None, authorization_header_value); + } + + #[tokio::test] + async fn default_background_agent_task_auth_returns_none_for_supported_host() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let auth_manager = AuthManager::from_auth_for_testing(auth.clone()); + let manager = BackgroundAgentTaskManager::new( + auth_manager, + "https://chatgpt.com/backend-api".to_string(), + SessionSource::Cli, + ); + + let authorization_header_value = manager + .authorization_header_value_for_auth(&auth) + .await + .expect("default manager should not fail"); + + assert_eq!(None, authorization_header_value); + } + + #[test] + fn cached_background_agent_task_auth_honors_disabled_mode() { + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let key_material = generate_agent_key_material().expect("generate key material"); + auth.set_agent_identity(AgentIdentityAuthRecord { + workspace_id: "account_id".to_string(), + chatgpt_user_id: None, + agent_runtime_id: "agent_123".to_string(), + agent_private_key: key_material.private_key_pkcs8_base64, + registered_at: "2026-04-13T12:00:00Z".to_string(), + background_task_id: Some("task_123".to_string()), + }) + .expect("set agent identity"); + + let disabled_authorization_header_value = + cached_background_agent_task_authorization_header_value( + &auth, + BackgroundAgentTaskAuthMode::Disabled, + ) + .expect("disabled cached auth should not fail"); + let enabled_authorization_header_value = + cached_background_agent_task_authorization_header_value( + &auth, + BackgroundAgentTaskAuthMode::Enabled, + ) + .expect("enabled cached auth should not fail"); + + assert_eq!(None, disabled_authorization_header_value); + assert!(enabled_authorization_header_value.is_some()); + } +} diff --git a/codex-rs/login/src/auth/agent_assertion.rs b/codex-rs/login/src/auth/agent_assertion.rs index e4a1731f6..ce24c57d0 100644 --- a/codex-rs/login/src/auth/agent_assertion.rs +++ b/codex-rs/login/src/auth/agent_assertion.rs @@ -167,6 +167,7 @@ mod tests { agent_runtime_id: agent_runtime_id.to_string(), agent_private_key: BASE64_STANDARD.encode(private_key.as_bytes()), registered_at: "2026-03-23T12:00:00Z".to_string(), + background_task_id: None, } } } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 30244ac26..c28b77a61 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -211,6 +211,7 @@ fn chatgpt_auth_persists_agent_identity_for_workspace() { agent_runtime_id: "agent_123".to_string(), agent_private_key: "pkcs8-base64".to_string(), registered_at: "2026-04-13T12:00:00Z".to_string(), + background_task_id: None, }; auth.set_agent_identity(record.clone()) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index bcfec4197..361d3722a 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -41,9 +41,12 @@ use codex_protocol::auth::KnownPlan as InternalKnownPlan; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; use codex_protocol::auth::RefreshTokenFailedReason; +use codex_protocol::protocol::SessionSource; use serde_json::Value; use thiserror::Error; +use crate::agent_identity::BackgroundAgentTaskAuthMode; +use crate::agent_identity::BackgroundAgentTaskManager; /// Authentication mechanism used by the current user. #[derive(Debug, Clone)] pub enum CodexAuth { @@ -372,7 +375,7 @@ impl CodexAuth { .filter(|identity| identity.workspace_id == workspace_id) } - pub fn set_agent_identity(&self, record: AgentIdentityAuthRecord) -> std::io::Result<()> { + pub fn set_agent_identity(&self, mut record: AgentIdentityAuthRecord) -> std::io::Result<()> { let (state, storage) = match self { Self::Chatgpt(auth) => (&auth.state, &auth.storage), Self::ChatgptAuthTokens(auth) => (&auth.state, &auth.storage), @@ -385,6 +388,13 @@ impl CodexAuth { let mut auth = guard .clone() .ok_or_else(|| std::io::Error::other("auth data is not available"))?; + if record.background_task_id.is_none() + && let Some(existing) = auth.agent_identity.as_ref() + && existing.workspace_id == record.workspace_id + && existing.agent_runtime_id == record.agent_runtime_id + { + record.background_task_id = existing.background_task_id.clone(); + } auth.agent_identity = Some(record); storage.save(&auth)?; *guard = Some(auth); @@ -1181,6 +1191,8 @@ pub struct AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>, + chatgpt_base_url: RwLock>, + background_agent_task_auth_mode: RwLock, refresh_lock: AsyncMutex<()>, external_auth: RwLock>>, auth_state_tx: watch::Sender<()>, @@ -1201,6 +1213,16 @@ pub trait AuthManagerConfig { /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. fn forced_chatgpt_workspace_id(&self) -> Option; + + /// Returns the ChatGPT backend base URL used for first-party backend authorization. + fn chatgpt_base_url(&self) -> Option { + None + } + + /// Returns whether default ChatGPT backend authorization may use background AgentAssertion. + fn background_agent_task_auth_mode(&self) -> BackgroundAgentTaskAuthMode { + BackgroundAgentTaskAuthMode::Disabled + } } impl Debug for AuthManager { @@ -1217,6 +1239,11 @@ impl Debug for AuthManager { "forced_chatgpt_workspace_id", &self.forced_chatgpt_workspace_id, ) + .field("chatgpt_base_url", &self.chatgpt_base_url) + .field( + "background_agent_task_auth_mode", + &self.background_agent_task_auth_mode, + ) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1249,6 +1276,8 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: RwLock::new(None), + background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled), refresh_lock: AsyncMutex::new(()), external_auth: RwLock::new(None), auth_state_tx, @@ -1269,6 +1298,8 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: RwLock::new(None), + background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled), refresh_lock: AsyncMutex::new(()), external_auth: RwLock::new(None), auth_state_tx, @@ -1288,6 +1319,8 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: RwLock::new(None), + background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled), refresh_lock: AsyncMutex::new(()), external_auth: RwLock::new(None), auth_state_tx, @@ -1305,6 +1338,8 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: RwLock::new(None), + background_agent_task_auth_mode: RwLock::new(BackgroundAgentTaskAuthMode::Disabled), refresh_lock: AsyncMutex::new(()), external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc @@ -1523,6 +1558,85 @@ impl AuthManager { Ok(Some(record)) } + pub fn set_chatgpt_backend_auth_config( + &self, + chatgpt_base_url: Option, + background_agent_task_auth_mode: BackgroundAgentTaskAuthMode, + ) { + let mut changed = false; + if let Ok(mut guard) = self.chatgpt_base_url.write() + && *guard != chatgpt_base_url + { + *guard = chatgpt_base_url; + changed = true; + } + if let Ok(mut guard) = self.background_agent_task_auth_mode.write() + && *guard != background_agent_task_auth_mode + { + *guard = background_agent_task_auth_mode; + changed = true; + } + if changed { + self.auth_state_tx.send_replace(()); + } + } + + fn chatgpt_backend_auth_config(&self) -> (Option, BackgroundAgentTaskAuthMode) { + let chatgpt_base_url = self + .chatgpt_base_url + .read() + .ok() + .and_then(|guard| guard.clone()); + let auth_mode = self + .background_agent_task_auth_mode + .read() + .ok() + .map(|guard| *guard) + .unwrap_or_default(); + (chatgpt_base_url, auth_mode) + } + + /// Returns the default authorization header for ChatGPT backend requests. + /// + /// This uses background AgentAssertion when configured and available, otherwise it falls back + /// to the ChatGPT bearer token. Low-level bootstrap calls that must never use AgentAssertion + /// should use [`Self::chatgpt_bearer_authorization_header_for_auth`] instead. + pub async fn chatgpt_authorization_header(self: &Arc) -> Option { + let auth = self.auth().await?; + self.chatgpt_authorization_header_for_auth(&auth).await + } + + pub async fn chatgpt_authorization_header_for_auth( + self: &Arc, + auth: &CodexAuth, + ) -> Option { + if !auth.is_chatgpt_auth() { + return None; + } + + let (chatgpt_base_url, auth_mode) = self.chatgpt_backend_auth_config(); + let Some(chatgpt_base_url) = chatgpt_base_url else { + return Self::chatgpt_bearer_authorization_header_for_auth(auth); + }; + + BackgroundAgentTaskManager::new_with_auth_mode( + Arc::clone(self), + chatgpt_base_url, + SessionSource::Cli, + auth_mode, + ) + .authorization_header_value_or_bearer(auth) + .await + } + + pub fn chatgpt_bearer_token_for_auth(auth: &CodexAuth) -> Option { + auth.get_token().ok().filter(|token| !token.is_empty()) + } + + pub fn chatgpt_bearer_authorization_header_for_auth(auth: &CodexAuth) -> Option { + Self::chatgpt_bearer_token_for_auth(auth).map(|token| format!("Bearer {token}")) + } + pub fn subscribe_auth_state(&self) -> watch::Receiver<()> { self.auth_state_tx.subscribe() } @@ -1565,6 +1679,10 @@ impl AuthManager { config.cli_auth_credentials_store_mode(), ); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); + auth_manager.set_chatgpt_backend_auth_config( + config.chatgpt_base_url(), + config.background_agent_task_auth_mode(), + ); auth_manager } diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index f0d7a3169..5b752ee67 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -52,6 +52,8 @@ pub struct AgentIdentityAuthRecord { pub agent_runtime_id: String, pub agent_private_key: String, pub registered_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background_task_id: Option, } pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 2e1cc8502..e00f09d46 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -69,6 +69,7 @@ async fn file_storage_persists_agent_identity() -> anyhow::Result<()> { agent_runtime_id: "agent_123".to_string(), agent_private_key: "pkcs8-base64".to_string(), registered_at: "2026-04-13T12:00:00Z".to_string(), + background_task_id: None, }), }; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 046d878e8..af0e13b9b 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_identity; pub mod auth; pub mod auth_env_telemetry; pub mod token_data; @@ -17,6 +18,8 @@ pub use server::ServerOptions; pub use server::ShutdownHandle; pub use server::run_login_server; +pub use agent_identity::BackgroundAgentTaskAuthMode; +pub use agent_identity::cached_background_agent_task_authorization_header_value; pub use auth::AgentIdentityAuthRecord; pub use auth::AgentTaskAuthorizationTarget; pub use auth::AuthConfig;