Compare commits

...

1 Commits

Author SHA1 Message Date
alandelong-oai
f39ffdb6cb Add generic plugin backend bridge 2026-04-29 12:32:30 -04:00
9 changed files with 846 additions and 1 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1892,6 +1892,7 @@ dependencies = [
"opentelemetry_sdk",
"owo-colors",
"pretty_assertions",
"rand 0.9.3",
"reqwest",
"rmcp",
"serde",

View File

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

View File

@@ -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::<ConnectionId, OutboundConnectionState>::new();

View File

@@ -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<AuthManager>,
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<AuthManager>,
bridge_configs: HashMap<String, PluginBackendBridgeConfigToml>,
shutdown_token: CancellationToken,
) -> io::Result<Vec<JoinHandle<()>>> {
let bridge_shutdown_token = shutdown_token.child_token();
let mut bridge_configs = bridge_configs.into_iter().collect::<Vec<_>>();
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<AuthManager>,
bridge_id: String,
config: PluginBackendBridgeConfigToml,
shutdown_token: CancellationToken,
) -> io::Result<JoinHandle<()>> {
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::<PluginBackendBridgeStateFile>(&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<PluginBackendBridgeState>,
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<String> {
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<reqwest::Url> {
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<Response> {
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<AuthManager>) -> io::Result<CodexAuth> {
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<reqwest::Response> {
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<Response> {
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::<serde_json::Value>(&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::<HeaderMap>::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::<HeaderMap>::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<ExternalAuthTokens> {
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)
}
}

View File

@@ -178,6 +178,11 @@ pub struct ConfigToml {
#[schemars(schema_with = "crate::schema::mcp_servers_schema")]
pub mcp_servers: HashMap<String, McpServerConfig>,
/// 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<String, PluginBackendBridgeConfigToml>,
/// 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<String>,
}
#[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 {

View File

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

View File

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

View File

@@ -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<HashMap<String, McpServerConfig>>,
/// Named loopback bridges that let trusted local plugins call scoped authenticated
/// ChatGPT backend subtrees without receiving user credentials directly.
pub plugin_backend_bridges: HashMap<String, PluginBackendBridgeConfigToml>,
/// 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(

View File

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