diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 65d581db6f..147c5fed87 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4282,6 +4282,7 @@ dependencies = [ "codex-exec-server", "codex-utils-absolute-path", "codex-utils-path-uri", + "pretty_assertions", "serde", "serde_json", "tempfile", diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 4a765e6a62..6c036e4694 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -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 { diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 31c31960c7..4cc79bff5f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -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> { + 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"); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 05ce110953..7e71622bf5 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -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 Deserialize<'de>>( request: RequestBuilder, url: &str, ) -> Result { - 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 Deserialize<'de>>( source, }) } + +pub(super) async fn send_plugin_service_request( + request: RequestBuilder, +) -> Result { + 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 { + 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::>(); + let existing_cookie_headers = existing_cookie_headers + .iter() + .map(Vec::as_slice) + .collect::>(); + 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 +} diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index c400d27c47..961257d245 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -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(), diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index f1d4478d7f..bef7f543f5 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -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"), + ); +} diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml index cdfb600c8c..7ca29ecef2 100644 --- a/codex-rs/utils/plugins/Cargo.toml +++ b/codex-rs/utils/plugins/Cargo.toml @@ -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"] } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index 203b1f22e4..58a641b171 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -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; diff --git a/codex-rs/utils/plugins/src/plugin_service_routing.rs b/codex-rs/utils/plugins/src/plugin_service_routing.rs new file mode 100644 index 0000000000..fbb3e78b49 --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_service_routing.rs @@ -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> { + 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::>(); + + 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; diff --git a/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs b/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs new file mode 100644 index 0000000000..a2c3c3917c --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs @@ -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()), + ); +}