diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 147c5fed87..ef47818555 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2300,6 +2300,7 @@ dependencies = [ "codex-plugin", "codex-utils-cargo-bin", "codex-utils-cli", + "codex-utils-plugins", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index c2cbb02830..89a0279cd7 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -18,6 +18,7 @@ codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-plugin = { workspace = true } codex-utils-cli = { workspace = true } +codex-utils-plugins = { workspace = true } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 372f62e696..543bb9cf42 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,6 +1,9 @@ use codex_core::config::Config; use codex_login::AuthManager; +use codex_login::default_client::chatgpt_cloudflare_cookie_header; use codex_login::default_client::create_client; +use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled; +use codex_utils_plugins::plugin_service_routing::plugin_service_routing_cookie; use anyhow::Context; use serde::de::DeserializeOwned; @@ -9,18 +12,54 @@ use std::time::Duration; const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; const CODEX_PRODUCT_SKU: &str = "codex"; +#[derive(Clone, Copy)] +enum ChatgptRequestRouting { + Default, + PluginService, +} + /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, path: String, ) -> anyhow::Result { - chatgpt_get_request_with_timeout(config, path, /*timeout*/ None).await + chatgpt_get_request_with_timeout_inner( + config, + path, + /*timeout*/ None, + ChatgptRequestRouting::Default, + ) + .await } pub(crate) async fn chatgpt_get_request_with_timeout( config: &Config, path: String, timeout: Option, +) -> anyhow::Result { + chatgpt_get_request_with_timeout_inner(config, path, timeout, ChatgptRequestRouting::Default) + .await +} + +pub(crate) async fn chatgpt_get_plugin_service_request_with_timeout( + config: &Config, + path: String, + timeout: Option, +) -> anyhow::Result { + chatgpt_get_request_with_timeout_inner( + config, + path, + timeout, + ChatgptRequestRouting::PluginService, + ) + .await +} + +async fn chatgpt_get_request_with_timeout_inner( + config: &Config, + path: String, + timeout: Option, + routing: ChatgptRequestRouting, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; let auth_manager = @@ -52,6 +91,16 @@ pub(crate) async fn chatgpt_get_request_with_timeout( .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU) .header("Content-Type", "application/json"); + if matches!(routing, ChatgptRequestRouting::PluginService) && plugin_service_preview_enabled() { + let cloudflare_cookie = chatgpt_cloudflare_cookie_header(&url); + let existing_cookie_headers = cloudflare_cookie.as_deref().into_iter().collect::>(); + if let Some(routing_cookie) = + plugin_service_routing_cookie(&existing_cookie_headers, /*preview_enabled*/ true) + { + request = request.header("Cookie", routing_cookie); + } + } + if let Some(timeout) = timeout { request = request.timeout(timeout); } diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index fa1a894cca..b3817f9876 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::time::Duration; -use crate::chatgpt_client::chatgpt_get_request_with_timeout; +use crate::chatgpt_client::chatgpt_get_plugin_service_request_with_timeout; use codex_app_server_protocol::AppInfo; use codex_connectors::ConnectorDirectoryCacheContext; @@ -21,6 +21,7 @@ pub use codex_core::connectors::with_app_enabled_state; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; +use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled; const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); @@ -101,7 +102,7 @@ pub async fn list_all_connectors_with_options( auth.is_workspace_account(), force_refetch, |path| async move { - chatgpt_get_request_with_timeout::( + chatgpt_get_plugin_service_request_with_timeout::( config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT), @@ -127,7 +128,8 @@ fn connector_directory_cache_context( auth.get_account_id(), auth.get_chatgpt_user_id(), auth.is_workspace_account(), - ), + ) + .plugin_service_preview(plugin_service_preview_enabled()), ) } diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs index c5f4bbd4eb..d97de8a830 100644 --- a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs @@ -55,6 +55,18 @@ pub fn with_chatgpt_cloudflare_cookie_store( builder.cookie_provider(Arc::clone(&SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE)) } +/// Returns the Cloudflare infrastructure cookies that the shared Codex cookie store would attach +/// to `url`. +/// +/// Use this only when a caller must construct an explicit `Cookie` header. Reqwest does not add +/// cookie-provider values when that header is already present. +pub fn chatgpt_cloudflare_cookie_header(url: &str) -> Option> { + let url = reqwest::Url::parse(url).ok()?; + SHARED_CHATGPT_CLOUDFLARE_COOKIE_STORE + .cookies(&url) + .map(|value| value.as_bytes().to_vec()) +} + fn is_chatgpt_cookie_url(url: &reqwest::Url) -> bool { match url.scheme() { "https" => {} diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 78daa6c288..13ec94754c 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -10,6 +10,7 @@ mod sse; mod telemetry; mod transport; +pub use crate::chatgpt_cloudflare_cookies::chatgpt_cloudflare_cookie_header; pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; pub use crate::custom_ca::BuildCustomCaTransportError; diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 53b546b8a0..b01b351e49 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -16,6 +16,7 @@ use anyhow::Context; use codex_login::CodexAuth; use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::sanitize_name; +use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled; use serde::Deserialize; use serde::Serialize; use sha1::Digest; @@ -26,6 +27,8 @@ pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, pub(crate) chatgpt_user_id: Option, pub(crate) is_workspace_account: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub(crate) plugin_service_preview: bool, } pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { @@ -33,6 +36,7 @@ pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCac account_id: auth.and_then(CodexAuth::get_account_id), chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id), is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account), + plugin_service_preview: plugin_service_preview_enabled(), } } diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 05b3917027..ed4c1a7661 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -86,6 +86,7 @@ fn create_codex_apps_tools_cache_context( account_id: account_id.map(ToOwned::to_owned), chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), is_workspace_account: false, + plugin_service_preview: false, }, } } @@ -611,6 +612,24 @@ fn codex_apps_tools_cache_is_scoped_per_user() { ); } +#[test] +fn codex_apps_tools_cache_is_scoped_by_plugin_service_preview() { + let codex_home = tempdir().expect("tempdir"); + let regular = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let mut preview = regular.clone(); + preview.user_key.plugin_service_preview = true; + + assert_ne!(regular.tools_cache_path(), preview.tools_cache_path()); + assert_ne!( + regular.server_info_cache_path(), + preview.server_info_cache_path() + ); +} + #[test] fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() { let codex_home = tempdir().expect("tempdir"); @@ -1263,6 +1282,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { account_id: None, chatgpt_user_id: None, is_workspace_account: false, + plugin_service_preview: false, }, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index a75120c0a4..8a391f150b 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -33,6 +33,8 @@ pub struct ConnectorDirectoryCacheKey { account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + plugin_service_preview: bool, } impl ConnectorDirectoryCacheKey { @@ -47,8 +49,14 @@ impl ConnectorDirectoryCacheKey { account_id, chatgpt_user_id, is_workspace_account, + plugin_service_preview: false, } } + + pub fn plugin_service_preview(mut self, plugin_service_preview: bool) -> Self { + self.plugin_service_preview = plugin_service_preview; + self + } } #[derive(Clone)] @@ -492,6 +500,18 @@ mod tests { ConnectorDirectoryCacheContext::new(codex_home.path().to_path_buf(), cache_key(id)) } + #[test] + fn directory_cache_is_scoped_by_plugin_service_preview() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let regular = cache_context(&codex_home, "user"); + let preview = ConnectorDirectoryCacheContext::new( + codex_home.path().to_path_buf(), + cache_key("user").plugin_service_preview(true), + ); + + assert_ne!(regular.cache_path(), preview.cache_path()); + } + fn clear_directory_memory_cache() { let mut cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 7e71622bf5..b5f26b9829 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -10,6 +10,7 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::chatgpt_cloudflare_cookie_header; use codex_plugin::AppConnectorId; use codex_plugin::AppDeclaration; use codex_plugin::PluginId; @@ -1878,12 +1879,31 @@ async fn send_plugin_service_request_with_preview( ) -> Result { let (client, request) = request.build_split(); let mut request = request?; - let headers = request.headers_mut(); - let existing_cookie_headers = headers + let cloudflare_cookie = (preview_enabled && !request.headers().contains_key(COOKIE)) + .then(|| chatgpt_cloudflare_cookie_header(request.url().as_str())) + .flatten(); + apply_plugin_service_routing_cookie( + request.headers_mut(), + preview_enabled, + cloudflare_cookie.as_deref(), + ); + + client.execute(request).await +} + +fn apply_plugin_service_routing_cookie( + headers: &mut reqwest::header::HeaderMap, + preview_enabled: bool, + cloudflare_cookie: Option<&[u8]>, +) { + let mut existing_cookie_headers = headers .get_all(COOKIE) .iter() .map(|value| value.as_bytes().to_vec()) .collect::>(); + if let Some(cloudflare_cookie) = cloudflare_cookie { + existing_cookie_headers.push(cloudflare_cookie.to_vec()); + } let existing_cookie_headers = existing_cookie_headers .iter() .map(Vec::as_slice) @@ -1896,6 +1916,4 @@ async fn send_plugin_service_request_with_preview( { headers.insert(COOKIE, routing_cookie); } - - client.execute(request).await } diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs index d49885dfbb..52161cec3b 100644 --- a/codex-rs/core-plugins/src/remote/catalog_cache.rs +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -1,6 +1,7 @@ use super::RemotePluginDirectoryItem; use super::RemotePluginServiceConfig; use codex_login::CodexAuth; +use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled; use serde::Deserialize; use serde::Serialize; use std::path::Path; @@ -16,6 +17,8 @@ struct RemotePluginCatalogCacheKey { account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + plugin_service_preview: bool, } impl RemotePluginCatalogCacheKey { @@ -25,6 +28,7 @@ impl RemotePluginCatalogCacheKey { account_id: auth.get_account_id(), chatgpt_user_id: auth.get_chatgpt_user_id(), is_workspace_account: auth.is_workspace_account(), + plugin_service_preview: plugin_service_preview_enabled(), } } } @@ -109,3 +113,7 @@ fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> Pat .join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR) .join(format!("{cache_key_hash:016x}.json")) } + +#[cfg(test)] +#[path = "catalog_cache_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs b/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs new file mode 100644 index 0000000000..60232274df --- /dev/null +++ b/codex-rs/core-plugins/src/remote/catalog_cache_tests.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn remote_plugin_catalog_cache_is_scoped_by_plugin_service_preview() { + let regular = RemotePluginCatalogCacheKey { + chatgpt_base_url: "https://chatgpt.com/backend-api".to_string(), + account_id: Some("account".to_string()), + chatgpt_user_id: Some("user".to_string()), + is_workspace_account: true, + plugin_service_preview: false, + }; + let preview = RemotePluginCatalogCacheKey { + plugin_service_preview: true, + ..regular.clone() + }; + + assert_ne!( + cache_path(Path::new("/codex-home"), ®ular), + cache_path(Path::new("/codex-home"), &preview) + ); +} diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index bef7f543f5..60a44bf978 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -299,6 +299,23 @@ fn recommended_plugins_ignore_invalid_remote_plugin_ids() { ); } +#[test] +fn plugin_service_routing_preserves_cloudflare_cookie_jar_values() { + let mut headers = reqwest::header::HeaderMap::new(); + let cloudflare_cookie = HeaderValue::from_static("cf_clearance=clearance; _cfuvid=visitor"); + + apply_plugin_service_routing_cookie( + &mut headers, + /*preview_enabled*/ true, + Some(cloudflare_cookie.as_bytes()), + ); + + assert_eq!( + headers.get(COOKIE).and_then(|value| value.to_str().ok()), + Some("cf_clearance=clearance; _cfuvid=visitor; oai-chat-plugin-service-preview=true"), + ); +} + #[tokio::test] async fn plugin_service_request_does_not_add_preview_cookie_when_disabled() { let server = MockServer::start().await; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 87c3097162..aa442f23d9 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -41,6 +41,7 @@ use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers; use codex_mcp::tool_plugin_provenance; +use codex_utils_plugins::plugin_service_routing::plugin_service_preview_enabled; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -460,7 +461,8 @@ async fn cached_directory_connectors_for_tool_suggest_with_auth( Some(account_id), auth.get_chatgpt_user_id(), is_workspace_account, - ), + ) + .plugin_service_preview(plugin_service_preview_enabled()), ); codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default() diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca..5bbc8d5e4e 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -11,6 +11,7 @@ use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; +pub use codex_client::chatgpt_cloudflare_cookie_header; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; use reqwest::header::HeaderMap; diff --git a/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs b/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs index a2c3c3917c..3dfab4fe17 100644 --- a/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs +++ b/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs @@ -22,10 +22,19 @@ fn preview_signal_requires_exact_enabled_value() { #[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, + plugin_service_routing_cookie(&[], /*preview_enabled*/ false), + None + ); + assert_eq!( + plugin_service_routing_cookie( + &[ + b"session=abc; oai-chat-plugin-service-preview=true".as_slice(), + b"theme=dark".as_slice(), + ], + /*preview_enabled*/ false, + ), + Some(b"session=abc; theme=dark".to_vec()), ); } @@ -37,7 +46,7 @@ fn routing_cookie_preserves_unrelated_cookies_and_replaces_caller_value() { b"session=abc; oai-chat-plugin-service-preview=false".as_slice(), b"theme=dark; oai-chat-plugin-service-preview=true".as_slice(), ], - true, + /*preview_enabled*/ true, ), Some(b"session=abc; theme=dark; oai-chat-plugin-service-preview=true".to_vec()), );