diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 17c51f58a95b..013a93ce928b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2296,12 +2296,14 @@ dependencies = [ "clap", "codex-connectors", "codex-core", + "codex-features", "codex-git-utils", "codex-login", "codex-model-provider", "codex-plugin", "codex-utils-cargo-bin", "codex-utils-cli", + "codex-utils-plugins", "pretty_assertions", "serde", "serde_json", @@ -4331,6 +4333,7 @@ dependencies = [ "codex-exec-server", "codex-utils-absolute-path", "codex-utils-path-uri", + "pretty_assertions", "serde", "serde_json", "tempfile", diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d123f0379c5d..b7480cf930eb 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -117,6 +117,13 @@ fn load_shared_plugin_ids_by_local_path( }) } +fn remote_plugin_service_config(config: &Config) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + plugin_service_preview: config.features.enabled(Feature::PluginServicePreview), + } +} + fn share_context_for_source( source: &MarketplacePluginSource, shared_plugin_ids_by_local_path: &std::collections::BTreeMap, @@ -569,9 +576,7 @@ impl PluginRequestProcessor { !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); let use_remote_global_catalog = include_global_remote && auth_mode.is_some_and(DomainAuthMode::uses_codex_backend); - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let refresh_global_remote_catalog_cache = use_remote_global_catalog && codex_core_plugins::remote::has_cached_global_remote_plugin_catalog( config.codex_home.as_path(), @@ -1018,9 +1023,7 @@ impl PluginRequestProcessor { ); let share_context = match share_context { Some(context) => { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); match codex_core_plugins::remote::fetch_remote_plugin_share_context( &remote_plugin_service_config, auth.as_ref(), @@ -1124,9 +1127,7 @@ impl PluginRequestProcessor { "remote plugin read is not enabled for marketplace {remote_marketplace_name}" ))); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); validate_remote_plugin_id(&plugin_name)?; let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( &remote_plugin_service_config, @@ -1182,9 +1183,7 @@ impl PluginRequestProcessor { } let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( &remote_plugin_service_config, auth.as_ref(), @@ -1235,9 +1234,7 @@ impl PluginRequestProcessor { validate_client_plugin_share_targets(share_targets)?; } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { discoverability: discoverability.map(remote_plugin_share_discoverability), share_targets: share_targets.map(remote_plugin_share_targets), @@ -1278,9 +1275,7 @@ impl PluginRequestProcessor { } validate_client_plugin_share_targets(&share_targets)?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let result = codex_core_plugins::remote::update_remote_plugin_share_targets( &remote_plugin_service_config, auth.as_ref(), @@ -1308,9 +1303,7 @@ impl PluginRequestProcessor { _params: PluginShareListParams, ) -> Result { let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let data = codex_core_plugins::remote::list_remote_plugin_shares( &remote_plugin_service_config, auth.as_ref(), @@ -1347,9 +1340,7 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let result = codex_core_plugins::remote::checkout_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1380,9 +1371,7 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); codex_core_plugins::remote::delete_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), @@ -1517,9 +1506,7 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&remote_plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( &remote_plugin_service_config, @@ -1995,9 +1982,7 @@ impl PluginRequestProcessor { validate_remote_plugin_id(&plugin_id)?; let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; + let remote_plugin_service_config = remote_plugin_service_config(&config); let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target( &remote_plugin_service_config, auth.as_ref(), diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 3cb15ad1a9c0..2811e606ddc2 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -12,11 +12,13 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-connectors = { workspace = true } codex-core = { workspace = true } +codex-features = { workspace = true } codex-git-utils = { workspace = true } 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 372f62e6966d..80643c688197 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_features::Feature; 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_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,18 @@ 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) + && config.features.enabled(Feature::PluginServicePreview) + { + 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 bc84e5126dc7..c9ce3d88fde2 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_connectors::AppInfo; use codex_connectors::ConnectorDirectoryCacheContext; @@ -18,6 +18,7 @@ pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_o pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status; pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools; pub use codex_core::connectors::with_app_enabled_state; +use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; @@ -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(config.features.enabled(Feature::PluginServicePreview)), ) } diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs index c5f4bbd4eb1e..d97de8a83071 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 78daa6c28817..13ec94754c77 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_cache.rs b/codex-rs/codex-mcp/src/codex_apps_cache.rs index ea3944462883..b1e22ed146f0 100644 --- a/codex-rs/codex-mcp/src/codex_apps_cache.rs +++ b/codex-rs/codex-mcp/src/codex_apps_cache.rs @@ -29,7 +29,7 @@ use crate::tools::ToolInfo; const MCP_TOOLS_CACHE_PUBLISH_DURATION_METRIC: &str = "codex.mcp.tools.cache_publish.duration_ms"; -/// The CodexAuth bits that identify a Codex Apps catalog. +/// The auth and routing inputs that identify a Codex Apps catalog. /// /// Debug bearer-token overrides bypass the shared cache, so shared entries only /// need the CodexAuth-backed identity. @@ -38,14 +38,20 @@ 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, } /// Builds the CodexAuth-backed Codex Apps cache key. -pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey { +pub fn codex_apps_tools_cache_key( + auth: Option<&CodexAuth>, + plugin_service_preview: bool, +) -> CodexAppsToolsCacheKey { CodexAppsToolsCacheKey { 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, } } diff --git a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs index 46ad311a1b56..4edbc8737bb1 100644 --- a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs +++ b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs @@ -55,6 +55,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, }, ) } @@ -134,6 +135,22 @@ 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 cache = CodexAppsToolsCache::default(); + let regular = cache.context( + codex_home.path().to_path_buf(), + codex_apps_tools_cache_key(/*auth*/ None, /*plugin_service_preview*/ false), + ); + let preview = cache.context( + codex_home.path().to_path_buf(), + codex_apps_tools_cache_key(/*auth*/ None, /*plugin_service_preview*/ true), + ); + + assert_ne!(regular.tools_cache_path(), preview.tools_cache_path()); +} + #[test] fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() { let codex_home = tempdir().expect("tempdir"); @@ -361,6 +378,7 @@ fn codex_apps_tools_cache_publishes_newest_shared_snapshot() { account_id: Some("account-one".to_string()), chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, + plugin_service_preview: false, }, ); let cache_context_2 = cache.context( @@ -369,6 +387,7 @@ fn codex_apps_tools_cache_publishes_newest_shared_snapshot() { account_id: Some("account-one".to_string()), chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, + plugin_service_preview: false, }, ); let older_ticket = cache_context_1.begin_fetch(CodexAppsToolsFetchSource::Startup); @@ -413,6 +432,7 @@ fn codex_apps_tools_cache_keeps_live_publish_when_disk_persistence_fails() { account_id: Some("account-one".to_string()), chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, + plugin_service_preview: false, }, ); let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "live")]; @@ -442,6 +462,7 @@ fn codex_apps_tools_cache_scopes_non_utf8_home_disk_paths() { account_id: Some("account-one".to_string()), chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, + plugin_service_preview: false, }, ); let user_two_context = cache.context( @@ -450,6 +471,7 @@ fn codex_apps_tools_cache_scopes_non_utf8_home_disk_paths() { account_id: Some("account-two".to_string()), chatgpt_user_id: Some("user-two".to_string()), is_workspace_account: false, + plugin_service_preview: false, }, ); let cache_paths = [ diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index d2869fe5c174..9d2ca5390cab 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -74,6 +74,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, }, ) } @@ -1111,6 +1112,7 @@ fn server_metadata_preserves_tool_approval_policy() { let mut config = crate::codex_apps_mcp_server_config( "https://docs.example", /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, ); config.environment_id = "remote".to_string(); config.default_tools_approval_mode = Some(AppToolApproval::Prompt); @@ -1155,6 +1157,7 @@ fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() { let server = EffectiveMcpServer::configured(crate::codex_apps_mcp_server_config( "https://chatgpt.com", /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, )); manager.server_metadata.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), @@ -1248,6 +1251,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/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index ffb797f26e69..bd1265a3af0d 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -37,6 +37,7 @@ 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 rmcp::model::ElicitationCapability; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; @@ -140,6 +141,8 @@ pub struct McpConfig { /// ChatGPT auth is checked separately before a materialized host-owned Apps /// server can be used. pub apps_enabled: bool, + /// Whether built-in plugin-service requests should use preview routing. + pub plugin_service_preview: bool, /// Whether model-visible MCP tool namespaces should keep the legacy /// `mcp__` prefix. pub prefix_mcp_tool_names: bool, @@ -332,7 +335,7 @@ pub async fn read_mcp_resource( runtime_context, config.codex_home.clone(), codex_apps_tools_cache, - codex_apps_tools_cache_key(auth), + codex_apps_tools_cache_key(auth, config.plugin_service_preview), config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, @@ -409,7 +412,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_context, config.codex_home.clone(), codex_apps_tools_cache, - codex_apps_tools_cache_key(auth), + codex_apps_tools_cache_key(auth, config.plugin_service_preview), config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, @@ -488,11 +491,13 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { pub fn codex_apps_mcp_server_config( chatgpt_base_url: &str, apps_mcp_product_sku: Option<&str>, + plugin_service_preview: bool, ) -> McpServerConfig { mcp_server_config_for_url( codex_apps_mcp_url_for_base_url(chatgpt_base_url), apps_mcp_product_sku, McpServerAuth::ChatGpt, + plugin_service_preview, ) } @@ -500,6 +505,7 @@ pub fn codex_apps_mcp_server_config( pub fn hosted_plugin_runtime_mcp_server_config( chatgpt_base_url: &str, apps_mcp_product_sku: Option<&str>, + plugin_service_preview: bool, ) -> McpServerConfig { let base_url = normalize_codex_apps_base_url(chatgpt_base_url); let base_url = if base_url.contains("/backend-api") || base_url.contains("/api/codex") { @@ -511,6 +517,7 @@ pub fn hosted_plugin_runtime_mcp_server_config( format!("{base_url}/ps/mcp"), apps_mcp_product_sku, McpServerAuth::ChatGpt, + plugin_service_preview, ) } @@ -518,10 +525,19 @@ fn mcp_server_config_for_url( url: String, apps_mcp_product_sku: Option<&str>, auth_mode: McpServerAuth, + preview_enabled: bool, ) -> McpServerConfig { - let http_headers = apps_mcp_product_sku.map(|product_sku| { - HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.to_string())]) - }); + 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 7d166cbab80f..c6bbc0eb0891 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -31,6 +31,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { codex_linux_sandbox_exe: None, use_legacy_landlock: false, apps_enabled: false, + plugin_service_preview: false, prefix_mcp_tool_names: true, client_elicitation_capability: ElicitationCapability::default(), mcp_server_catalog: ResolvedMcpCatalog::default(), @@ -130,7 +131,11 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { "alpha".to_string(), McpPluginAttribution::new("alpha@test".to_string(), "alpha-plugin".to_string()), /*plugin_order*/ 0, - codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None), + codex_apps_mcp_server_config( + "https://alpha.example", + /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, + ), )); config.mcp_server_catalog = catalog.build(); config.connector_snapshot = @@ -197,7 +202,11 @@ fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() { "Executor GitHub".to_string(), ), /*selection_order*/ 0, - codex_apps_mcp_server_config("https://github.example", /*apps_mcp_product_sku*/ None), + codex_apps_mcp_server_config( + "https://github.example", + /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, + ), )); config.mcp_server_catalog = catalog.build(); config.connector_snapshot = @@ -252,8 +261,11 @@ fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { #[test] fn codex_apps_server_config_uses_legacy_codex_apps_path() { - let config = - codex_apps_mcp_server_config("https://chatgpt.com", /*apps_mcp_product_sku*/ None); + let config = codex_apps_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, + ); let url = match &config.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), @@ -264,7 +276,12 @@ 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"), + McpServerAuth::ChatGpt, + /*preview_enabled*/ false, + ); match &config.transport { McpServerTransportConfig::StreamableHttp { @@ -285,11 +302,41 @@ 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 = hosted_plugin_runtime_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, + ); + let enabled = hosted_plugin_runtime_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ 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(), + )])), + ); +} + #[tokio::test] async fn effective_mcp_servers_preserve_runtime_servers() { let codex_home = tempfile::tempdir().expect("tempdir"); let mut config = test_mcp_config(codex_home.path().to_path_buf()); config.apps_enabled = true; + config.plugin_service_preview = true; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let mut catalog = ResolvedMcpCatalog::builder(); @@ -350,6 +397,7 @@ async fn effective_mcp_servers_preserve_runtime_servers() { codex_apps_mcp_server_config( &config.chatgpt_base_url, config.apps_mcp_product_sku.as_deref(), + config.plugin_service_preview, ), )); config.mcp_server_catalog = catalog.build(); @@ -375,8 +423,11 @@ async fn effective_mcp_servers_preserve_runtime_servers() { .expect("codex apps should use configured transport"); match &sample.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, http_headers, .. + } => { assert_eq!(url, "https://user.example/mcp"); + assert_eq!(http_headers, &None); } other => panic!("expected streamable http transport, got {other:?}"), } @@ -387,8 +438,17 @@ async fn effective_mcp_servers_preserve_runtime_servers() { other => panic!("expected streamable http transport, got {other:?}"), } match &codex_apps.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, http_headers, .. + } => { assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); + assert_eq!( + http_headers, + &Some(HashMap::from([( + "Cookie".to_string(), + "oai-chat-plugin-service-preview=true".to_string(), + )])) + ); } other => panic!("expected streamable http transport, got {other:?}"), } diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index f77936e88fab..b0cd4ef37bc3 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -42,6 +42,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 { @@ -56,8 +58,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)] @@ -526,6 +534,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/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 6e411cdda3e9..412108231e3a 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -850,6 +850,7 @@ remote_plugin = true codex_home.path(), &RemotePluginServiceConfig { chatgpt_base_url: plugins.chatgpt_base_url.clone(), + plugin_service_preview: false, }, Some(&auth), ) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b357c5b53643..1ae81b060c9d 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -104,6 +104,7 @@ pub struct PluginsConfigInput { pub config_layer_stack: ConfigLayerStack, pub plugins_enabled: bool, pub remote_plugin_enabled: bool, + pub plugin_service_preview: bool, pub chatgpt_base_url: String, } @@ -118,9 +119,15 @@ impl PluginsConfigInput { config_layer_stack, plugins_enabled, remote_plugin_enabled, + plugin_service_preview: false, chatgpt_base_url, } } + + pub fn plugin_service_preview(mut self, plugin_service_preview: bool) -> Self { + self.plugin_service_preview = plugin_service_preview; + self + } } /// Inputs used to select endpoint-backed plugin install candidates. @@ -219,6 +226,7 @@ struct ConfiguredMarketplaceUpgradeState { fn remote_plugin_service_config(config: &PluginsConfigInput) -> RemotePluginServiceConfig { RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), + plugin_service_preview: config.plugin_service_preview, } } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 515bbb71c00a..13a4ddedc228 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::PluginCapabilitySummary; @@ -17,7 +18,10 @@ use codex_plugin::PluginId; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_utils_absolute_path::AbsolutePathBuf; +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; @@ -120,6 +124,7 @@ const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 6] = [ #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { pub chatgpt_base_url: String, + pub plugin_service_preview: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -829,7 +834,7 @@ pub async fn fetch_recommended_plugins( let request = authenticated_request(client.get(&url), auth)? .timeout(RECOMMENDED_PLUGINS_TIMEOUT) .query(&[("scope", "GLOBAL")]); - let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?; + let response: RecommendedPluginsResponse = send_and_decode(config, request, &url).await?; Ok(recommended_plugins_mode(response)) } @@ -1134,7 +1139,7 @@ pub async fn fetch_remote_plugin_skill_detail( let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?; let client = build_reqwest_client(); let request = authenticated_request(client.get(&url), auth)?; - let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?; + let response: RemotePluginSkillDetailResponse = send_and_decode(config, request, &url).await?; if response.plugin_id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), @@ -1295,7 +1300,7 @@ pub async fn install_remote_plugin( .query(&[("includeAppsNeedingAuth", "true")]), auth, )?; - let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; + let response: RemotePluginMutationResponse = send_and_decode(config, request, &url).await?; if response.id != plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: plugin_id.to_string(), @@ -1383,7 +1388,7 @@ pub async fn uninstall_remote_plugin( let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/uninstall"); let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?; - let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?; + let response: RemotePluginMutationResponse = send_and_decode(config, request, &url).await?; if response.id != remote_plugin_id { return Err(RemotePluginCatalogError::UnexpectedPluginId { expected: remote_plugin_id, @@ -1802,7 +1807,7 @@ async fn get_remote_plugin_list_page( if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } async fn get_remote_shared_workspace_plugins_page( @@ -1818,7 +1823,7 @@ async fn get_remote_shared_workspace_plugins_page( if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } async fn get_remote_plugin_installed_page( @@ -1839,7 +1844,7 @@ async fn get_remote_plugin_installed_page( if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } async fn fetch_plugin_detail( @@ -1855,7 +1860,7 @@ async fn fetch_plugin_detail( if include_download_urls { request = request.query(&[("includeDownloadUrls", true)]); } - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } fn remote_plugin_skill_detail_url( @@ -1900,11 +1905,11 @@ fn authenticated_request( } async fn send_and_decode Deserialize<'de>>( + config: &RemotePluginServiceConfig, request: RequestBuilder, url: &str, ) -> Result { - let response = request - .send() + let response = send_plugin_service_request(config, request) .await .map_err(|source| RemotePluginCatalogError::Request { url: url.to_string(), @@ -1925,3 +1930,49 @@ async fn send_and_decode Deserialize<'de>>( source, }) } + +pub(super) async fn send_plugin_service_request( + config: &RemotePluginServiceConfig, + request: RequestBuilder, +) -> Result { + let preview_enabled = config.plugin_service_preview; + let (client, request) = request.build_split(); + let mut request = request?; + 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) + .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); + } +} diff --git a/codex-rs/core-plugins/src/remote/catalog_cache.rs b/codex-rs/core-plugins/src/remote/catalog_cache.rs index d49885dfbb60..a1dc0280d26e 100644 --- a/codex-rs/core-plugins/src/remote/catalog_cache.rs +++ b/codex-rs/core-plugins/src/remote/catalog_cache.rs @@ -16,6 +16,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 +27,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: config.plugin_service_preview, } } } @@ -109,3 +112,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 000000000000..60232274dffd --- /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/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 2a23b066edaa..7c385bf7a92e 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -544,6 +544,7 @@ mod tests { .await; let config = RemotePluginServiceConfig { chatgpt_base_url: format!("{}/backend-api", server.uri()), + plugin_service_preview: false, }; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index c400d27c47d4..12c75d79f0b9 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -281,7 +281,7 @@ pub async fn delete_remote_plugin_share( let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}"); let client = build_reqwest_client(); let request = authenticated_request(client.delete(&url), auth)?; - send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await?; + send_and_expect_status(config, request, &url, &[StatusCode::NO_CONTENT]).await?; if let Err(err) = local_paths::remove_plugin_share_local_path(codex_home, remote_plugin_id) { warn!( remote_plugin_id = %remote_plugin_id, @@ -319,7 +319,8 @@ pub async fn update_remote_plugin_share_targets( targets, }, ); - let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + let response: RemotePluginShareUpdateTargetsResponse = + send_and_decode(config, request, &url).await?; Ok(RemotePluginShareUpdateTargetsResult { principals: response.principals, discoverability: response.discoverability, @@ -384,7 +385,7 @@ async fn get_created_workspace_plugins_page( if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } async fn create_workspace_plugin_upload( @@ -405,7 +406,7 @@ async fn create_workspace_plugin_upload( plugin_id: remote_plugin_id, }, ); - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } async fn put_workspace_plugin_upload( @@ -452,7 +453,7 @@ async fn finalize_workspace_plugin_upload( }; let client = build_reqwest_client(); let request = authenticated_request(client.post(&url), auth)?.json(&body); - send_and_decode(request, &url).await + send_and_decode(config, request, &url).await } fn archive_filename(plugin_path: &Path) -> Result { @@ -489,12 +490,12 @@ fn archive_plugin_for_upload_with_limit( } async fn send_and_expect_status( + config: &RemotePluginServiceConfig, request: RequestBuilder, url_for_error: &str, expected_statuses: &[StatusCode], ) -> Result<(), RemotePluginCatalogError> { - let response = request - .send() + let response = send_plugin_service_request(config, request) .await .map_err(|source| RemotePluginCatalogError::Request { url: url_for_error.to_string(), diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index c8d06b234dec..8a2e445945d5 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -25,6 +25,7 @@ use wiremock::matchers::query_param_is_missing; fn test_config(server: &MockServer) -> RemotePluginServiceConfig { RemotePluginServiceConfig { chatgpt_base_url: format!("{}/backend-api", server.uri()), + plugin_service_preview: false, } } diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index f1d4478d7f46..7a0b3417ba19 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -294,3 +294,26 @@ fn recommended_plugins_ignore_invalid_remote_plugin_ids() { } ); } + +#[test] +fn plugin_service_routing_preserves_cloudflare_cookie_jar_values() { + let mut headers = reqwest::header::HeaderMap::from_iter([( + COOKIE, + HeaderValue::from_static("session=abc; oai-chat-plugin-service-preview=false"), + )]); + 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( + "session=abc; cf_clearance=clearance; _cfuvid=visitor; \ + oai-chat-plugin-service-preview=true" + ), + ); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 512c050a20c1..9a0ec3bbb2b3 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -571,6 +571,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_service_preview": { + "type": "boolean" + }, "plugin_sharing": { "type": "boolean" }, @@ -4934,6 +4937,9 @@ "plugin_hooks": { "type": "boolean" }, + "plugin_service_preview": { + "type": "boolean" + }, "plugin_sharing": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6f7114928e85..a7519bd2ca92 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6003,6 +6003,34 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn plugin_service_preview_cli_override_reaches_plugin_service_consumers() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(vec![( + "features.plugin_service_preview".to_string(), + TomlValue::Boolean(true), + )]) + .build() + .await?; + + assert!(config.features.enabled(Feature::PluginServicePreview)); + assert!(config.plugins_config_input().plugin_service_preview); + assert!( + config + .to_mcp_config(&plugins_manager) + .await + .plugin_service_preview + ); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d90d010102d5..222ff8a652d4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1486,6 +1486,7 @@ impl Config { self.features.enabled(Feature::RemotePlugin), self.chatgpt_base_url.clone(), ) + .plugin_service_preview(self.features.enabled(Feature::PluginServicePreview)) } /// Applies managed MCP requirements to servers supplied by one plugin. @@ -1573,6 +1574,7 @@ impl Config { codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), use_legacy_landlock: self.features.use_legacy_landlock(), apps_enabled: self.features.enabled(Feature::Apps), + plugin_service_preview: self.features.enabled(Feature::PluginServicePreview), prefix_mcp_tool_names: self.prefix_mcp_tool_names(), client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) { ElicitationCapability { diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b45cc841a1a0..9d3fee0f83c3 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -276,7 +276,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( runtime_context, config.codex_home.to_path_buf(), mcp_manager.codex_apps_tools_cache(), - codex_apps_tools_cache_key(auth.as_ref()), + codex_apps_tools_cache_key(auth.as_ref(), mcp_config.plugin_service_preview), mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, /*supports_openai_form_elicitation*/ false, @@ -465,7 +465,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(config.features.enabled(Feature::PluginServicePreview)), ); codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default() diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index 45ca0d563d24..0c962c7c7ffa 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -166,6 +166,7 @@ impl McpManager { codex_apps_mcp_server_config( &mcp_config.chatgpt_base_url, mcp_config.apps_mcp_product_sku.as_deref(), + mcp_config.plugin_service_preview, ), )); } else { diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 6b642b1cd0a2..2cccbf3b6e22 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1411,6 +1411,7 @@ async fn host_owned_codex_apps_manager( codex_mcp::EffectiveMcpServer::configured(codex_mcp::codex_apps_mcp_server_config( "https://chatgpt.com", /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, )), )]); let manager = codex_mcp::McpConnectionManager::new( @@ -1432,7 +1433,13 @@ async fn host_owned_codex_apps_manager( ), turn_context.config.codex_home.to_path_buf(), session.services.mcp_manager.codex_apps_tools_cache(), - codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), + codex_mcp::codex_apps_tools_cache_key( + auth.as_ref(), + turn_context + .config + .features + .enabled(codex_features::Feature::PluginServicePreview), + ), turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index 2d92e70964a9..06f6862fb731 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -224,6 +224,7 @@ remote_plugin = true codex_home.path(), &RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), + plugin_service_preview: false, }, Some(&auth), ) diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 83a59c960a03..df6beefd98cd 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -330,7 +330,7 @@ impl Session { mcp_runtime_context.clone(), mcp_config.codex_home.clone(), self.services.mcp_manager.codex_apps_tools_cache(), - codex_apps_tools_cache_key(auth.as_ref()), + codex_apps_tools_cache_key(auth.as_ref(), mcp_config.plugin_service_preview), mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability.clone(), self.services diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index 34cf07cc35a8..185f13d76731 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -70,6 +70,7 @@ impl McpRuntimeSnapshot { codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: config.features.use_legacy_landlock(), apps_enabled: config.features.enabled(Feature::Apps), + plugin_service_preview: config.features.enabled(Feature::PluginServicePreview), prefix_mcp_tool_names: config.prefix_mcp_tool_names(), client_elicitation_capability: ElicitationCapability::default(), mcp_server_catalog: ResolvedMcpCatalog::default(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index ed07352b152c..1587b96ccd96 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1198,7 +1198,12 @@ impl Session { mcp_runtime_context.clone(), config.codex_home.to_path_buf(), sess.services.mcp_manager.codex_apps_tools_cache(), - codex_apps_tools_cache_key(auth), + codex_apps_tools_cache_key( + auth, + config + .features + .enabled(codex_features::Feature::PluginServicePreview), + ), config.prefix_mcp_tool_names(), mcp_config.client_elicitation_capability.clone(), sess.services diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 5148f1625962..12a430da2386 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -494,6 +494,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() let mut server = codex_mcp::codex_apps_mcp_server_config( "https://selected.invalid", /*apps_mcp_product_sku*/ None, + /*plugin_service_preview*/ false, ); let CapabilityRootLocation::Environment { environment_id, .. } = &selected_root.location; diff --git a/codex-rs/ext/mcp/src/lib.rs b/codex-rs/ext/mcp/src/lib.rs index 9c22bad5a0a1..86d3692ecde8 100644 --- a/codex-rs/ext/mcp/src/lib.rs +++ b/codex-rs/ext/mcp/src/lib.rs @@ -32,6 +32,9 @@ impl McpServerContributor for HostedPluginRuntimeExtension { config: Box::new(hosted_plugin_runtime_mcp_server_config( &config.chatgpt_base_url, config.apps_mcp_product_sku.as_deref(), + config + .features + .enabled(codex_features::Feature::PluginServicePreview), )), }] }) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3bce1a7d127f..22b84f44a5af 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -190,6 +190,11 @@ pub enum Feature { ComputerUse, /// Temporary internal-only flag for PS-backed remote plugin catalog development. RemotePlugin, + /// Route eligible plugin-service requests to the preview deployment. + /// + /// This is a routing selector, not an authorization boundary. The gateway must only honor the + /// corresponding cookie for internal traffic. + PluginServicePreview, /// Enable remote plugin sharing flows. PluginSharing, /// Removed compatibility flag retained as a no-op. @@ -1140,6 +1145,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::PluginServicePreview, + key: "plugin_service_preview", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::PluginSharing, key: "plugin_sharing", diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca97..5bbc8d5e4e5e 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/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml index cdfb600c8c20..7ca29ecef2a5 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 5cb590549545..e762c1215f35 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 000000000000..92dad6b66f4d --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_service_routing.rs @@ -0,0 +1,56 @@ +const PLUGIN_SERVICE_PREVIEW_COOKIE_NAME: &[u8] = b"oai-chat-plugin-service-preview"; +/// Routing cookie added to eligible plugin-service requests. +/// +/// This cookie is intentionally public and untrusted. It selects a deployment after normal +/// authentication; the gateway must independently restrict preview routing to internal traffic. +pub const PLUGIN_SERVICE_PREVIEW_COOKIE: &str = "oai-chat-plugin-service-preview=true"; + +/// 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 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 000000000000..ea73636e4098 --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_service_routing_tests.rs @@ -0,0 +1,34 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn routing_cookie_is_disabled_by_default_and_cannot_be_enabled_by_caller() { + assert_eq!( + 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()), + ); +} + +#[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(), + ], + /*preview_enabled*/ true, + ), + Some(b"session=abc; theme=dark; oai-chat-plugin-service-preview=true".to_vec()), + ); +}