[plugins] Route preview traffic to plugin service

This commit is contained in:
Steven Lee
2026-06-16 19:53:50 +00:00
parent 6e0c8b4517
commit 7db1786573
10 changed files with 304 additions and 10 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -4282,6 +4282,7 @@ dependencies = [
"codex-exec-server",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",

View File

@@ -33,6 +33,8 @@ use codex_protocol::mcp::Tool;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::McpAuthStatus;
use codex_utils_plugins::plugin_service_routing::PLUGIN_SERVICE_PREVIEW_COOKIE;
use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled;
use rmcp::model::ElicitationCapability;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
@@ -455,6 +457,7 @@ pub fn codex_apps_mcp_server_config(
mcp_server_config_for_url(
codex_apps_mcp_url_for_base_url(chatgpt_base_url),
apps_mcp_product_sku,
plugin_service_preview_enabled(),
)
}
@@ -469,13 +472,29 @@ pub fn hosted_plugin_runtime_mcp_server_config(
} else {
format!("{base_url}/api/codex")
};
mcp_server_config_for_url(format!("{base_url}/ps/mcp"), apps_mcp_product_sku)
mcp_server_config_for_url(
format!("{base_url}/ps/mcp"),
apps_mcp_product_sku,
plugin_service_preview_enabled(),
)
}
fn mcp_server_config_for_url(url: String, apps_mcp_product_sku: Option<&str>) -> McpServerConfig {
let http_headers = apps_mcp_product_sku.map(|product_sku| {
HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())])
});
fn mcp_server_config_for_url(
url: String,
apps_mcp_product_sku: Option<&str>,
preview_enabled: bool,
) -> McpServerConfig {
let mut http_headers = HashMap::new();
if let Some(product_sku) = apps_mcp_product_sku {
http_headers.insert("X-OpenAI-Product-Sku".to_string(), product_sku.to_string());
}
if preview_enabled {
http_headers.insert(
"Cookie".to_string(),
PLUGIN_SERVICE_PREVIEW_COOKIE.to_string(),
);
}
let http_headers = (!http_headers.is_empty()).then_some(http_headers);
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {

View File

@@ -261,7 +261,11 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
#[test]
fn codex_apps_server_config_forwards_configured_product_sku_header() {
let config = codex_apps_mcp_server_config("https://chatgpt.com", Some("tpp"));
let config = mcp_server_config_for_url(
codex_apps_mcp_url_for_base_url("https://chatgpt.com"),
Some("tpp"),
/*preview_enabled*/ false,
);
match &config.transport {
McpServerTransportConfig::StreamableHttp {
@@ -282,6 +286,59 @@ fn codex_apps_server_config_forwards_configured_product_sku_header() {
}
}
#[test]
fn built_in_plugin_service_mcp_config_only_adds_preview_cookie_when_enabled() {
let disabled = mcp_server_config_for_url(
"https://chatgpt.com/backend-api/ps/mcp".to_string(),
/*apps_mcp_product_sku*/ None,
/*preview_enabled*/ false,
);
let enabled = mcp_server_config_for_url(
"https://chatgpt.com/backend-api/ps/mcp".to_string(),
/*apps_mcp_product_sku*/ None,
/*preview_enabled*/ true,
);
fn headers(config: &McpServerConfig) -> Option<&HashMap<String, String>> {
match &config.transport {
McpServerTransportConfig::StreamableHttp { http_headers, .. } => http_headers.as_ref(),
other => panic!("expected streamable http transport, got {other:?}"),
}
}
assert_eq!(headers(&disabled), None);
assert_eq!(
headers(&enabled),
Some(&HashMap::from([(
"Cookie".to_string(),
"oai-chat-plugin-service-preview=true".to_string(),
)])),
);
let unrelated_server = McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://third-party.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
};
assert_eq!(headers(&unrelated_server), None);
}
#[tokio::test]
async fn effective_mcp_servers_preserve_runtime_servers() {
let codex_home = tempfile::tempdir().expect("tempdir");

View File

@@ -15,7 +15,11 @@ use codex_plugin::AppDeclaration;
use codex_plugin::PluginId;
use codex_plugin::app_connector_ids_from_declarations;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled;
use codex_utils_plugins::plugin_service_routing::plugin_service_routing_cookie;
use reqwest::RequestBuilder;
use reqwest::header::COOKIE;
use reqwest::header::HeaderValue;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
@@ -1840,8 +1844,7 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
request: RequestBuilder,
url: &str,
) -> Result<T, RemotePluginCatalogError> {
let response = request
.send()
let response = send_plugin_service_request(request)
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: url.to_string(),
@@ -1862,3 +1865,37 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
source,
})
}
pub(super) async fn send_plugin_service_request(
request: RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
send_plugin_service_request_with_preview(request, plugin_service_preview_enabled()).await
}
async fn send_plugin_service_request_with_preview(
request: RequestBuilder,
preview_enabled: bool,
) -> Result<reqwest::Response, reqwest::Error> {
let (client, request) = request.build_split();
let mut request = request?;
let headers = request.headers_mut();
let existing_cookie_headers = headers
.get_all(COOKIE)
.iter()
.map(|value| value.as_bytes().to_vec())
.collect::<Vec<_>>();
let existing_cookie_headers = existing_cookie_headers
.iter()
.map(Vec::as_slice)
.collect::<Vec<_>>();
let routing_cookie = plugin_service_routing_cookie(&existing_cookie_headers, preview_enabled);
headers.remove(COOKIE);
if let Some(routing_cookie) = routing_cookie
&& let Ok(routing_cookie) = HeaderValue::from_bytes(&routing_cookie)
{
headers.insert(COOKIE, routing_cookie);
}
client.execute(request).await
}

View File

@@ -493,8 +493,7 @@ async fn send_and_expect_status(
url_for_error: &str,
expected_statuses: &[StatusCode],
) -> Result<(), RemotePluginCatalogError> {
let response = request
.send()
let response = send_plugin_service_request(request)
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: url_for_error.to_string(),

View File

@@ -1,5 +1,9 @@
use super::*;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
#[test]
fn build_remote_marketplace_preserves_directory_order_and_appends_installed_only_plugins() {
@@ -294,3 +298,60 @@ fn recommended_plugins_ignore_invalid_remote_plugin_ids() {
}
);
}
#[tokio::test]
async fn plugin_service_request_does_not_add_preview_cookie_when_disabled() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let response = send_plugin_service_request_with_preview(
reqwest::Client::new().get(server.uri()),
/*preview_enabled*/ false,
)
.await
.expect("request should succeed");
assert!(response.status().is_success());
let requests = server
.received_requests()
.await
.expect("request recording should be available");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].headers.get("cookie"), None);
}
#[tokio::test]
async fn plugin_service_request_sanitizes_and_preserves_caller_cookies() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let response = send_plugin_service_request_with_preview(
reqwest::Client::new().get(server.uri()).header(
COOKIE,
"session=abc; oai-chat-plugin-service-preview=false; theme=dark",
),
/*preview_enabled*/ true,
)
.await
.expect("request should succeed");
assert!(response.status().is_success());
let requests = server
.received_requests()
.await
.expect("request recording should be available");
assert_eq!(requests.len(), 1);
assert_eq!(
requests[0]
.headers
.get("cookie")
.and_then(|value| value.to_str().ok()),
Some("session=abc; theme=dark; oai-chat-plugin-service-preview=true"),
);
}

View File

@@ -21,5 +21,6 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -6,6 +6,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
pub mod mcp_connector;
pub mod mention_syntax;
pub mod plugin_namespace;
pub mod plugin_service_routing;
pub use plugin_namespace::DISCOVERABLE_PLUGIN_MANIFEST_PATHS;
pub use plugin_namespace::find_plugin_manifest_path;

View File

@@ -0,0 +1,74 @@
use std::ffi::OsStr;
/// Process signal set by an eligible host to opt plugin-service requests into preview routing.
pub const CODEX_PLUGIN_SERVICE_PREVIEW_ENV_VAR: &str = "CODEX_PLUGIN_SERVICE_PREVIEW";
const PLUGIN_SERVICE_PREVIEW_ENABLED_VALUE: &str = "1";
const PLUGIN_SERVICE_PREVIEW_COOKIE_NAME: &[u8] = b"oai-chat-plugin-service-preview";
/// Routing cookie added to eligible plugin-service requests.
pub const PLUGIN_SERVICE_PREVIEW_COOKIE: &str = "oai-chat-plugin-service-preview=true";
/// Returns whether the host opted this process into plugin-service preview routing.
///
/// The host owns employee eligibility. This signal is defense-in-depth routing, not an
/// authorization boundary; authentication and authorization remain the responsibility of the
/// existing request path and plugin-service.
pub fn plugin_service_preview_enabled() -> bool {
plugin_service_preview_enabled_from_value(
std::env::var_os(CODEX_PLUGIN_SERVICE_PREVIEW_ENV_VAR).as_deref(),
)
}
/// Rewrites plugin-service cookies so callers cannot override the process routing signal.
///
/// Unrelated cookies are preserved. Any caller-provided preview cookie is removed before the
/// canonical routing cookie is added when preview routing is enabled.
pub fn plugin_service_routing_cookie(
existing_cookie_headers: &[&[u8]],
preview_enabled: bool,
) -> Option<Vec<u8>> {
let mut cookies = existing_cookie_headers
.iter()
.flat_map(|header| header.split(|byte| *byte == b';'))
.map(trim_cookie_whitespace)
.filter(|segment| !segment.is_empty())
.filter(|segment| {
let name = segment
.iter()
.position(|byte| *byte == b'=')
.map_or(*segment, |separator| &segment[..separator]);
trim_cookie_whitespace(name) != PLUGIN_SERVICE_PREVIEW_COOKIE_NAME
})
.map(<[u8]>::to_vec)
.collect::<Vec<_>>();
if preview_enabled {
cookies.push(PLUGIN_SERVICE_PREVIEW_COOKIE.as_bytes().to_vec());
}
(!cookies.is_empty()).then(|| cookies.join(&b"; "[..]))
}
fn plugin_service_preview_enabled_from_value(value: Option<&OsStr>) -> bool {
value == Some(OsStr::new(PLUGIN_SERVICE_PREVIEW_ENABLED_VALUE))
}
fn trim_cookie_whitespace(mut value: &[u8]) -> &[u8] {
while value
.first()
.is_some_and(|byte| matches!(byte, b' ' | b'\t'))
{
value = &value[1..];
}
while value
.last()
.is_some_and(|byte| matches!(byte, b' ' | b'\t'))
{
value = &value[..value.len() - 1];
}
value
}
#[cfg(test)]
#[path = "plugin_service_routing_tests.rs"]
mod tests;

View File

@@ -0,0 +1,44 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn preview_signal_requires_exact_enabled_value() {
for value in [
None,
Some(""),
Some("0"),
Some("true"),
Some(" 1"),
Some("1 "),
] {
assert!(!plugin_service_preview_enabled_from_value(
value.map(OsStr::new)
));
}
assert!(plugin_service_preview_enabled_from_value(Some(OsStr::new(
"1"
))));
}
#[test]
fn routing_cookie_is_disabled_by_default_and_cannot_be_enabled_by_caller() {
assert_eq!(plugin_service_routing_cookie(&[], false), None);
assert_eq!(
plugin_service_routing_cookie(&[b"oai-chat-plugin-service-preview=true".as_slice()], false,),
None,
);
}
#[test]
fn routing_cookie_preserves_unrelated_cookies_and_replaces_caller_value() {
assert_eq!(
plugin_service_routing_cookie(
&[
b"session=abc; oai-chat-plugin-service-preview=false".as_slice(),
b"theme=dark; oai-chat-plugin-service-preview=true".as_slice(),
],
true,
),
Some(b"session=abc; theme=dark; oai-chat-plugin-service-preview=true".to_vec()),
);
}