From f39ffdb6cb43d8140731a9e2b2d5020cd09eebeb Mon Sep 17 00:00:00 2001 From: alandelong-oai Date: Tue, 28 Apr 2026 13:47:30 -0400 Subject: [PATCH] Add generic plugin backend bridge --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 2 + codex-rs/app-server/src/lib.rs | 16 +- .../app-server/src/plugin_backend_bridge.rs | 761 ++++++++++++++++++ codex-rs/config/src/config_toml.rs | 15 + codex-rs/core/config.schema.json | 26 + codex-rs/core/src/config/config_tests.rs | 4 + codex-rs/core/src/config/mod.rs | 6 + docs/config.md | 16 + 9 files changed, 846 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server/src/plugin_backend_bridge.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f219096e35..688ac4aded 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1892,6 +1892,7 @@ dependencies = [ "opentelemetry_sdk", "owo-colors", "pretty_assertions", + "rand 0.9.3", "reqwest", "rmcp", "serde", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f2beb6a124..5cce6167f7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -75,6 +75,8 @@ gethostname = { workspace = true } hmac = { workspace = true } jsonwebtoken = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] } +rand = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8f68fc95cc..af89370908 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -26,6 +26,7 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::QueuedOutgoingMessage; +use crate::plugin_backend_bridge::start_plugin_backend_bridges; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; @@ -90,6 +91,7 @@ pub mod in_process; mod message_processor; mod models; mod outgoing_message; +mod plugin_backend_bridge; mod request_serialization; mod server_request_error; mod thread_state; @@ -643,13 +645,24 @@ pub async fn run_main_with_transport_options( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let plugin_backend_bridge_handles = start_plugin_backend_bridges( + config.codex_home.to_path_buf(), + config.chatgpt_base_url.clone(), + auth_manager.clone(), + config.plugin_backend_bridges.clone(), + transport_shutdown_token.clone(), + ) + .await?; let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); if remote_control_config_enabled && state_db.is_none() { error!("remote control disabled because sqlite state db is unavailable"); } - if transport_accept_handles.is_empty() && !remote_control_enabled { + if transport_accept_handles.is_empty() + && plugin_backend_bridge_handles.is_empty() + && !remote_control_enabled + { return Err(std::io::Error::new( ErrorKind::InvalidInput, if remote_control_config_enabled && state_db.is_none() { @@ -671,6 +684,7 @@ pub async fn run_main_with_transport_options( ) .await?; transport_accept_handles.push(remote_control_accept_handle); + transport_accept_handles.extend(plugin_backend_bridge_handles); let outbound_handle = tokio::spawn(async move { let mut outbound_connections = HashMap::::new(); diff --git a/codex-rs/app-server/src/plugin_backend_bridge.rs b/codex-rs/app-server/src/plugin_backend_bridge.rs new file mode 100644 index 0000000000..05d2b66016 --- /dev/null +++ b/codex-rs/app-server/src/plugin_backend_bridge.rs @@ -0,0 +1,761 @@ +use axum::Router; +use axum::body::Body; +use axum::extract::Request; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::HeaderValue; +use axum::http::Method; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::http::header::AUTHORIZATION; +use axum::response::IntoResponse; +use axum::response::Response; +use axum::routing::any; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_config::config_toml::PluginBackendBridgeConfigToml; +use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; +use rand::RngCore as _; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; + +const STATE_FILE_PREFIX: &str = "plugin-backend-bridge."; +const STATE_FILE_SUFFIX: &str = ".json"; + +#[derive(Clone)] +struct PluginBackendBridgeState { + auth_manager: Arc, + chatgpt_base_url: String, + client: reqwest::Client, + backend_path_prefix: String, + local_path_prefix: String, + token: String, +} + +#[derive(Deserialize, Serialize)] +struct PluginBackendBridgeStateFile { + base_url: String, + token: String, +} + +pub(crate) async fn start_plugin_backend_bridges( + codex_home: PathBuf, + chatgpt_base_url: String, + auth_manager: Arc, + bridge_configs: HashMap, + shutdown_token: CancellationToken, +) -> io::Result>> { + let bridge_shutdown_token = shutdown_token.child_token(); + let mut bridge_configs = bridge_configs.into_iter().collect::>(); + bridge_configs.sort_by(|(left_id, _), (right_id, _)| left_id.cmp(right_id)); + let mut handles = Vec::with_capacity(bridge_configs.len()); + for (bridge_id, config) in bridge_configs { + match start_plugin_backend_bridge( + codex_home.clone(), + chatgpt_base_url.clone(), + auth_manager.clone(), + bridge_id, + config, + bridge_shutdown_token.clone(), + ) + .await + { + Ok(handle) => handles.push(handle), + Err(err) => { + bridge_shutdown_token.cancel(); + for handle in handles { + let _ = handle.await; + } + return Err(err); + } + } + } + Ok(handles) +} + +async fn start_plugin_backend_bridge( + codex_home: PathBuf, + chatgpt_base_url: String, + auth_manager: Arc, + bridge_id: String, + config: PluginBackendBridgeConfigToml, + shutdown_token: CancellationToken, +) -> io::Result> { + validate_bridge_id(&bridge_id)?; + validate_path_prefix("local_path_prefix", &config.local_path_prefix)?; + validate_path_prefix("backend_path_prefix", &config.backend_path_prefix)?; + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local_addr = listener.local_addr()?; + let state = PluginBackendBridgeStateFile { + base_url: format!("http://{local_addr}"), + token: random_bridge_token(), + }; + let state_file_path = codex_home.join(state_file_name(&bridge_id)); + + write_state_file(&codex_home, &state_file_path, &state).await?; + + let router = Router::new() + .fallback(any(handle_plugin_backend_bridge_request)) + .with_state(PluginBackendBridgeState { + auth_manager, + chatgpt_base_url, + client: build_reqwest_client(), + backend_path_prefix: config.backend_path_prefix, + local_path_prefix: config.local_path_prefix, + token: state.token.clone(), + }); + let server = axum::serve(listener, router).with_graceful_shutdown({ + let shutdown_token = shutdown_token.clone(); + async move { + shutdown_token.cancelled().await; + } + }); + + info!( + bridge_id, + base_url = state.base_url, + state_file_path = %state_file_path.display(), + "plugin backend bridge listening" + ); + Ok(tokio::spawn(async move { + if let Err(err) = server.await { + error!(bridge_id, "plugin backend bridge failed: {err}"); + } + if let Err(err) = remove_state_file_if_owned(&state_file_path, &state).await { + error!( + bridge_id, + state_file_path = %state_file_path.display(), + "failed to remove plugin backend bridge state file: {err}" + ); + } + info!(bridge_id, "plugin backend bridge shutting down"); + })) +} + +fn state_file_name(bridge_id: &str) -> String { + format!("{STATE_FILE_PREFIX}{bridge_id}{STATE_FILE_SUFFIX}") +} + +fn validate_bridge_id(bridge_id: &str) -> io::Result<()> { + if bridge_id.is_empty() + || !bridge_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "plugin backend bridge ids may contain only ASCII letters, digits, `_`, and `-`", + )); + } + Ok(()) +} + +fn validate_path_prefix(field_name: &str, path_prefix: &str) -> io::Result<()> { + if !path_prefix.starts_with('/') || !path_prefix.ends_with('/') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("plugin backend bridge {field_name} must start and end with `/`"), + )); + } + Ok(()) +} + +async fn write_state_file( + codex_home: &Path, + state_file_path: &Path, + state: &PluginBackendBridgeStateFile, +) -> io::Result<()> { + fs::create_dir_all(codex_home).await?; + let state_file = serde_json::to_vec(state).map_err(io::Error::other)?; + fs::write(state_file_path, state_file).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(state_file_path, std::fs::Permissions::from_mode(0o600)).await?; + } + Ok(()) +} + +async fn remove_state_file_if_owned( + state_file_path: &Path, + expected_state: &PluginBackendBridgeStateFile, +) -> io::Result<()> { + let payload = match fs::read(state_file_path).await { + Ok(payload) => payload, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err), + }; + let current_state = serde_json::from_slice::(&payload) + .map_err(io::Error::other)?; + if current_state.base_url == expected_state.base_url + && current_state.token == expected_state.token + { + fs::remove_file(state_file_path).await?; + } + Ok(()) +} + +async fn handle_plugin_backend_bridge_request( + State(state): State, + request: Request, +) -> Response { + if !has_valid_bridge_token(request.headers(), &state.token) { + return json_error( + StatusCode::UNAUTHORIZED, + "Unauthorized plugin backend bridge request.", + ); + } + if request.method() != Method::GET && request.method() != Method::POST { + return json_error( + StatusCode::METHOD_NOT_ALLOWED, + "Unsupported plugin backend bridge method.", + ); + } + let Some(backend_path) = build_backend_path( + request.uri(), + &state.local_path_prefix, + &state.backend_path_prefix, + ) else { + return json_error(StatusCode::NOT_FOUND, "Unknown plugin backend bridge path."); + }; + let method = request.method().clone(); + let body = match axum::body::to_bytes(request.into_body(), usize::MAX).await { + Ok(body) => body, + Err(err) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to read plugin backend bridge request body: {err}"), + ); + } + }; + + match forward_to_backend(&state, method, backend_path, body).await { + Ok(response) => response, + Err(err) => json_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Plugin backend bridge request failed: {err}"), + ), + } +} + +fn has_valid_bridge_token(headers: &HeaderMap, token: &str) -> bool { + headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some(&format!("Bearer {token}")) +} + +fn build_backend_path( + uri: &Uri, + local_path_prefix: &str, + backend_path_prefix: &str, +) -> Option { + let path = uri.path(); + if !path.starts_with(local_path_prefix) { + return None; + } + let mut backend_path = format!( + "{}{}", + backend_path_prefix.trim_end_matches('/'), + &path[local_path_prefix.len().saturating_sub(1)..] + ); + if let Some(query) = uri.query() { + backend_path.push('?'); + backend_path.push_str(query); + } + Some(backend_path) +} + +fn backend_url( + chatgpt_base_url: &str, + backend_path: &str, + backend_path_prefix: &str, +) -> io::Result { + let base_url = chatgpt_base_url.trim_end_matches('/'); + let backend_base_url = base_url.strip_suffix("/backend-api").unwrap_or(base_url); + let url = reqwest::Url::parse(&format!( + "{}/{}", + backend_base_url, + backend_path.trim_start_matches('/') + )) + .map_err(io::Error::other)?; + if !url.path().starts_with(backend_path_prefix) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Plugin backend bridge request escaped its configured backend path prefix.", + )); + } + Ok(url) +} + +async fn forward_to_backend( + state: &PluginBackendBridgeState, + method: Method, + backend_path: String, + body: axum::body::Bytes, +) -> io::Result { + let backend_url = backend_url( + &state.chatgpt_base_url, + &backend_path, + &state.backend_path_prefix, + )?; + let mut auth_recovery = state.auth_manager.unauthorized_recovery(); + loop { + let auth = bridge_auth(&state.auth_manager).await?; + let response = + send_backend_request(state, &auth, &method, backend_url.clone(), body.clone()) + .await + .map_err(io::Error::other)?; + if response.status() != reqwest::StatusCode::UNAUTHORIZED || !auth_recovery.has_next() { + return response_from_reqwest(response).await; + } + auth_recovery.next().await.map_err(io::Error::other)?; + } +} + +async fn bridge_auth(auth_manager: &Arc) -> io::Result { + let Some(auth) = auth_manager.auth().await else { + return Err(io::Error::other( + "Sign in to ChatGPT in Codex to use this bridge.", + )); + }; + if !auth.uses_codex_backend() { + return Err(io::Error::other( + "ChatGPT authentication is required to use this bridge.", + )); + } + Ok(auth) +} + +async fn send_backend_request( + state: &PluginBackendBridgeState, + auth: &CodexAuth, + method: &Method, + backend_url: reqwest::Url, + body: axum::body::Bytes, +) -> reqwest::Result { + let mut headers = codex_model_provider::auth_provider_from_auth(auth).to_auth_headers(); + if !body.is_empty() { + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + } + state + .client + .request(method.clone(), backend_url) + .headers(headers) + .body(body) + .send() + .await +} + +async fn response_from_reqwest(response: reqwest::Response) -> io::Result { + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .cloned(); + let body = response.bytes().await.map_err(io::Error::other)?; + let mut builder = Response::builder().status(status); + if let Some(content_type) = content_type { + builder = builder.header(reqwest::header::CONTENT_TYPE, content_type); + } + builder.body(Body::from(body)).map_err(io::Error::other) +} + +fn json_error(status: StatusCode, detail: &str) -> Response { + ( + status, + axum::Json(serde_json::json!({ + "detail": detail, + })), + ) + .into_response() +} + +fn random_bridge_token() -> String { + let mut bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::body::to_bytes; + use axum::routing::any; + use codex_config::types::AuthCredentialsStoreMode; + use codex_core::test_support::auth_manager_from_auth; + use codex_login::ExternalAuth; + use codex_login::ExternalAuthRefreshContext; + use codex_login::ExternalAuthTokens; + use pretty_assertions::assert_eq; + use std::sync::Mutex; + use tempfile::TempDir; + + #[test] + fn maps_only_configured_local_paths_to_backend_paths() { + let uri: Uri = "/local/example/jobs/abc?include=true" + .parse() + .expect("valid uri"); + assert_eq!( + build_backend_path(&uri, "/local/example/", "/api/codex/example/"), + Some("/api/codex/example/jobs/abc?include=true".to_string()) + ); + let unrelated_uri: Uri = "/not-example".parse().expect("valid uri"); + assert_eq!( + build_backend_path(&unrelated_uri, "/local/example/", "/api/codex/example/"), + None + ); + } + + #[test] + fn strips_backend_api_before_building_backend_url() { + assert_eq!( + backend_url( + "https://chatgpt.com/backend-api/", + "/api/codex/example/auth-test", + "/api/codex/example/", + ) + .expect("backend url should build") + .as_str(), + "https://chatgpt.com/api/codex/example/auth-test" + ); + assert_eq!( + backend_url( + "http://127.0.0.1:8061", + "/api/codex/example/auth-test", + "/api/codex/example/", + ) + .expect("backend url should build") + .as_str(), + "http://127.0.0.1:8061/api/codex/example/auth-test" + ); + } + + #[test] + fn rejects_backend_urls_that_escape_the_configured_prefix_after_normalization() { + assert!( + backend_url( + "https://chatgpt.com/backend-api/", + "/api/codex/example/../admin", + "/api/codex/example/", + ) + .is_err() + ); + assert!( + backend_url( + "https://chatgpt.com/backend-api/", + "/api/codex/example/%2e%2e/admin", + "/api/codex/example/", + ) + .is_err() + ); + } + + #[tokio::test] + async fn rejects_requests_without_the_bridge_token() { + let codex_home = TempDir::new().expect("temp dir should exist"); + let state = PluginBackendBridgeState { + auth_manager: AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + codex_config::types::AuthCredentialsStoreMode::Ephemeral, + /*chatgpt_base_url*/ None, + ) + .await, + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + client: build_reqwest_client(), + backend_path_prefix: "/api/codex/example/".to_string(), + local_path_prefix: "/local/example/".to_string(), + token: "bridge-token".to_string(), + }; + let request = Request::builder() + .uri("/local/example/auth-test") + .body(Body::empty()) + .expect("request should build"); + + let response = handle_plugin_backend_bridge_request(State(state), request).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should read"); + assert_eq!( + serde_json::from_slice::(&body).expect("valid json"), + serde_json::json!({"detail": "Unauthorized plugin backend bridge request."}) + ); + } + + #[tokio::test] + async fn removes_only_owned_state_files() { + let codex_home = TempDir::new().expect("temp dir should exist"); + let state_file_path = codex_home.path().join(state_file_name("example")); + let owned_state = PluginBackendBridgeStateFile { + base_url: "http://127.0.0.1:1111".to_string(), + token: "owned".to_string(), + }; + let replacement_state = PluginBackendBridgeStateFile { + base_url: "http://127.0.0.1:2222".to_string(), + token: "replacement".to_string(), + }; + + write_state_file(codex_home.path(), &state_file_path, &replacement_state) + .await + .expect("state file should write"); + remove_state_file_if_owned(&state_file_path, &owned_state) + .await + .expect("foreign state should be preserved"); + assert!(state_file_path.exists()); + + write_state_file(codex_home.path(), &state_file_path, &owned_state) + .await + .expect("state file should rewrite"); + remove_state_file_if_owned(&state_file_path, &owned_state) + .await + .expect("owned state should be removed"); + assert!(!state_file_path.exists()); + } + + #[tokio::test] + async fn rolls_back_started_bridges_when_later_config_fails() { + let codex_home = TempDir::new().expect("temp dir should exist"); + let valid_bridge_id = "a-valid".to_string(); + let valid_state_file_path = codex_home.path().join(state_file_name(&valid_bridge_id)); + let bridge_configs = HashMap::from([ + ( + valid_bridge_id, + PluginBackendBridgeConfigToml { + local_path_prefix: "/local/example/".to_string(), + backend_path_prefix: "/api/codex/example/".to_string(), + }, + ), + ( + "b-invalid".to_string(), + PluginBackendBridgeConfigToml { + local_path_prefix: "missing-leading-slash/".to_string(), + backend_path_prefix: "/api/codex/example/".to_string(), + }, + ), + ]); + + let result = start_plugin_backend_bridges( + codex_home.path().to_path_buf(), + "https://chatgpt.com/backend-api/".to_string(), + AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*chatgpt_base_url*/ None, + ) + .await, + bridge_configs, + CancellationToken::new(), + ) + .await; + + assert!(result.is_err()); + assert!(!valid_state_file_path.exists()); + } + + #[tokio::test] + async fn forwards_host_auth_headers_to_the_backend() { + let captured_headers = Arc::new(Mutex::new(Vec::::new())); + let (base_url, server_handle) = spawn_test_backend({ + let captured_headers = Arc::clone(&captured_headers); + move |headers| { + captured_headers + .lock() + .expect("capture lock should not be poisoned") + .push(headers); + StatusCode::OK + } + }) + .await; + let state = PluginBackendBridgeState { + auth_manager: auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()), + chatgpt_base_url: base_url, + client: build_reqwest_client(), + backend_path_prefix: "/api/codex/example/".to_string(), + local_path_prefix: "/local/example/".to_string(), + token: "bridge-token".to_string(), + }; + + let response = forward_to_backend( + &state, + Method::GET, + "/api/codex/example/auth-test".to_string(), + axum::body::Bytes::new(), + ) + .await + .expect("forward should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let headers = captured_headers + .lock() + .expect("capture lock should not be poisoned"); + assert_eq!(headers.len(), 1); + assert_eq!( + headers[0] + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer Access Token") + ); + assert_eq!( + headers[0] + .get("chatgpt-account-id") + .and_then(|value| value.to_str().ok()), + Some("account_id") + ); + server_handle.abort(); + } + + #[tokio::test] + async fn retries_once_with_refreshed_external_chatgpt_auth_after_401() { + let codex_home = TempDir::new().expect("temp dir should exist"); + let stale_token = fake_jwt("stale-token@example.com"); + let fresh_token = fake_jwt("fresh-token@example.com"); + codex_login::auth::login_with_chatgpt_auth_tokens( + codex_home.path(), + &stale_token, + "account_id", + /*chatgpt_plan_type*/ None, + ) + .expect("external chatgpt auth should save"); + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::Ephemeral, + /*chatgpt_base_url*/ None, + ) + .await; + auth_manager.set_external_auth(Arc::new(TestExternalAuth { + fresh_token: fresh_token.clone(), + })); + + let captured_headers = Arc::new(Mutex::new(Vec::::new())); + let (base_url, server_handle) = spawn_test_backend({ + let captured_headers = Arc::clone(&captured_headers); + move |headers| { + let mut captured = captured_headers + .lock() + .expect("capture lock should not be poisoned"); + captured.push(headers); + if captured.len() == 1 { + StatusCode::UNAUTHORIZED + } else { + StatusCode::OK + } + } + }) + .await; + let state = PluginBackendBridgeState { + auth_manager, + chatgpt_base_url: base_url, + client: build_reqwest_client(), + backend_path_prefix: "/api/codex/example/".to_string(), + local_path_prefix: "/local/example/".to_string(), + token: "bridge-token".to_string(), + }; + + let response = forward_to_backend( + &state, + Method::GET, + "/api/codex/example/auth-test".to_string(), + axum::body::Bytes::new(), + ) + .await + .expect("forward should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let headers = captured_headers + .lock() + .expect("capture lock should not be poisoned"); + assert_eq!(headers.len(), 2); + let expected_stale_header = format!("Bearer {stale_token}"); + let expected_fresh_header = format!("Bearer {fresh_token}"); + assert_eq!( + headers[0] + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(expected_stale_header.as_str()) + ); + assert_eq!( + headers[1] + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some(expected_fresh_header.as_str()) + ); + server_handle.abort(); + } + + #[derive(Clone)] + struct TestExternalAuth { + fresh_token: String, + } + + #[async_trait] + impl ExternalAuth for TestExternalAuth { + fn auth_mode(&self) -> codex_app_server_protocol::AuthMode { + codex_app_server_protocol::AuthMode::Chatgpt + } + + async fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> io::Result { + Ok(ExternalAuthTokens::chatgpt( + self.fresh_token.clone(), + "account_id", + /*chatgpt_plan_type*/ None, + )) + } + } + + fn fake_jwt(email: &str) -> String { + let header = serde_json::json!({ "alg": "none", "typ": "JWT" }); + let payload = serde_json::json!({ "email": email }); + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("header should serialize")); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).expect("payload should serialize")); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + async fn spawn_test_backend( + handler: impl Fn(HeaderMap) -> StatusCode + Clone + Send + Sync + 'static, + ) -> (String, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let router = Router::new().route( + "/api/codex/example/auth-test", + any(move |headers: HeaderMap| { + let handler = handler.clone(); + async move { handler(headers) } + }), + ); + let handle = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("server should run"); + }); + (format!("http://{address}"), handle) + } +} diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 3bd19f5568..daaa1957a2 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -178,6 +178,11 @@ pub struct ConfigToml { #[schemars(schema_with = "crate::schema::mcp_servers_schema")] pub mcp_servers: HashMap, + /// Named loopback bridges that let trusted local plugins call scoped authenticated + /// ChatGPT backend subtrees without receiving user credentials directly. + #[serde(default)] + pub plugin_backend_bridges: HashMap, + /// Preferred backend for storing MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// https://github.com/openai/codex/blob/main/codex-rs/rmcp-client/src/oauth.rs#L2 @@ -420,6 +425,16 @@ pub struct ConfigToml { pub oss_provider: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct PluginBackendBridgeConfigToml { + /// Path prefix exposed on the loopback bridge listener. + pub local_path_prefix: String, + + /// Authenticated ChatGPT backend path prefix that receives forwarded requests. + pub backend_path_prefix: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ThreadStoreToml { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a17426d508..f5e31fbafc 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1729,6 +1729,24 @@ ], "type": "string" }, + "PluginBackendBridgeConfigToml": { + "additionalProperties": false, + "properties": { + "backend_path_prefix": { + "description": "Authenticated ChatGPT backend path prefix that receives forwarded requests.", + "type": "string" + }, + "local_path_prefix": { + "description": "Path prefix exposed on the loopback bridge listener.", + "type": "string" + } + }, + "required": [ + "backend_path_prefix", + "local_path_prefix" + ], + "type": "object" + }, "PluginConfig": { "additionalProperties": false, "properties": { @@ -3761,6 +3779,14 @@ "plan_mode_reasoning_effort": { "$ref": "#/definitions/ReasoningEffort" }, + "plugin_backend_bridges": { + "additionalProperties": { + "$ref": "#/definitions/PluginBackendBridgeConfigToml" + }, + "default": {}, + "description": "Named loopback bridges that let trusted local plugins call scoped authenticated ChatGPT backend subtrees without receiving user credentials directly.", + "type": "object" + }, "plugins": { "additionalProperties": { "$ref": "#/definitions/PluginConfig" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 097e93baac..d353913309 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5907,6 +5907,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), + plugin_backend_bridges: HashMap::new(), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( Default::default(), LOCAL_DEV_BUILD_VERSION, @@ -6101,6 +6102,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), + plugin_backend_bridges: HashMap::new(), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( Default::default(), LOCAL_DEV_BUILD_VERSION, @@ -6249,6 +6251,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), + plugin_backend_bridges: HashMap::new(), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( Default::default(), LOCAL_DEV_BUILD_VERSION, @@ -6382,6 +6385,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { cwd: fixture.cwd(), cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), + plugin_backend_bridges: HashMap::new(), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( Default::default(), LOCAL_DEV_BUILD_VERSION, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fe9a8e4334..0b6f341bcb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -23,6 +23,7 @@ use codex_config::SandboxModeRequirement; use codex_config::Sourced; use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::PluginBackendBridgeConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; @@ -529,6 +530,10 @@ pub struct Config { /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: Constrained>, + /// Named loopback bridges that let trusted local plugins call scoped authenticated + /// ChatGPT backend subtrees without receiving user credentials directly. + pub plugin_backend_bridges: HashMap, + /// Preferred store for MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. @@ -2674,6 +2679,7 @@ impl Config { env!("CARGO_PKG_VERSION"), ), mcp_servers, + plugin_backend_bridges: cfg.plugin_backend_bridges.clone(), // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( diff --git a/docs/config.md b/docs/config.md index 8dda2b6393..c861a6a2bf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -58,6 +58,22 @@ disabled_tools = [ ] ``` +## Plugin backend bridges + +Codex can expose named loopback bridges for trusted local plugins that need to call a +scoped authenticated backend path through the app server: + +```toml +[plugin_backend_bridges.example] +local_path_prefix = "/local/example/" +backend_path_prefix = "/api/codex/example/" +``` + +Each bridge listens on loopback only, accepts requests under its configured local +prefix, and forwards them to the configured backend prefix using the signed-in +Codex account. Keep each bridge scoped to one backend capability instead of using a +broad catch-all prefix. + ## Notify Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings: