mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
[plugins] Route preview traffic to plugin service
This commit is contained in:
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -4282,6 +4282,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
74
codex-rs/utils/plugins/src/plugin_service_routing.rs
Normal file
74
codex-rs/utils/plugins/src/plugin_service_routing.rs
Normal 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;
|
||||
44
codex-rs/utils/plugins/src/plugin_service_routing_tests.rs
Normal file
44
codex-rs/utils/plugins/src/plugin_service_routing_tests.rs
Normal 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()),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user