diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 023c5dfaa3..dea9889d43 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3988,6 +3988,7 @@ dependencies = [ name = "codex-skills-extension" version = "0.0.0" dependencies = [ + "codex-core-plugins", "codex-core-skills", "codex-exec-server", "codex-extension-api", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7b878e952c..861366be0e 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -801,6 +801,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1682,6 +1683,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -1986,6 +1988,7 @@ async fn compaction_event_ingests_custom_fact() { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), @@ -2116,6 +2119,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), }, product_client_id: DEFAULT_ORIGINATOR.to_string(), diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 4eec8a2348..a49c16ba71 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -349,6 +349,8 @@ pub struct InProcessClientStartArgs { pub experimental_api: bool, /// Whether MCP servers may send `openai/form` elicitation requests. pub mcp_server_openai_form_elicitation: bool, + /// Host features available to runtime plugins. + pub host_capabilities: Vec, /// Notification methods this client opts out of receiving. pub opt_out_notification_methods: Vec, /// Queue capacity for command/event channels (clamped to at least 1). @@ -374,6 +376,8 @@ impl InProcessClientStartArgs { Some(self.opt_out_notification_methods.clone()) }, mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, + host_capabilities: (!self.host_capabilities.is_empty()) + .then(|| self.host_capabilities.clone()), }; InitializeParams { @@ -1045,6 +1049,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity, }) @@ -1239,6 +1244,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: 8, } @@ -1257,6 +1263,20 @@ mod tests { ); } + #[test] + fn remote_initialize_params_forward_host_capabilities() { + let mut args = test_remote_connect_args("ws://localhost/rpc".to_string()); + args.host_capabilities = vec!["codex.inline_visualization".to_string()]; + + assert_eq!( + args.initialize_params() + .capabilities + .expect("initialize capabilities") + .host_capabilities, + Some(vec!["codex.inline_visualization".to_string()]) + ); + } + #[tokio::test] async fn typed_request_roundtrip_works() { let client = start_test_client(SessionSource::Exec).await; @@ -1528,6 +1548,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1617,6 +1638,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -1637,6 +1659,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: 8, }) @@ -2241,18 +2264,22 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: true, + host_capabilities: vec!["codex.inline_visualization".to_string()], opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } .into_runtime_start_args(); assert_eq!(runtime_args.config, config); - assert!( - runtime_args - .initialize - .capabilities - .expect("initialize capabilities") - .mcp_server_openai_form_elicitation + let initialize_capabilities = runtime_args + .initialize + .capabilities + .as_ref() + .expect("initialize capabilities"); + assert!(initialize_capabilities.mcp_server_openai_form_elicitation); + assert_eq!( + initialize_capabilities.host_capabilities, + Some(vec!["codex.inline_visualization".to_string()]) ); assert!(Arc::ptr_eq( &runtime_args.environment_manager, @@ -2290,6 +2317,7 @@ mod tests { client_version: "0.0.0-test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, } diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 5575fe4ad4..40796986ba 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -87,6 +87,7 @@ pub struct RemoteAppServerConnectArgs { pub client_version: String, pub experimental_api: bool, pub mcp_server_openai_form_elicitation: bool, + pub host_capabilities: Vec, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } @@ -101,6 +102,8 @@ impl RemoteAppServerConnectArgs { Some(self.opt_out_notification_methods.clone()) }, mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation, + host_capabilities: (!self.host_capabilities.is_empty()) + .then(|| self.host_capabilities.clone()), }; InitializeParams { diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d391cac165..086190bfb4 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1279,6 +1279,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "hostCapabilities": { + "description": "Host features available to runtime plugins on this connection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b7526b080d..f0eda21290 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2923,6 +2923,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "hostCapabilities": { + "description": "Host features available to runtime plugins on this connection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e1afc7883a..5c2bca1925 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7532,6 +7532,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "hostCapabilities": { + "description": "Host features available to runtime plugins on this connection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 75f0860ddd..3dbeddf00c 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -30,6 +30,16 @@ "description": "Opt into receiving experimental API methods and fields.", "type": "boolean" }, + "hostCapabilities": { + "description": "Host features available to runtime plugins on this connection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "mcpServerOpenaiFormElicitation": { "description": "Allow downstream MCP servers to request OpenAI extended form elicitations.", "type": "boolean" diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index dcc4dffb0b..9f42d4bf1e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -18,6 +18,10 @@ requestAttestation: boolean, * Allow downstream MCP servers to request OpenAI extended form elicitations. */ mcpServerOpenaiFormElicitation?: boolean, +/** + * Host features available to runtime plugins on this connection. + */ +hostCapabilities?: Array | null, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 27a8b0205d..ae6ab74457 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2209,6 +2209,7 @@ mod tests { experimental_api: true, request_attestation: true, mcp_server_openai_form_elicitation: true, + host_capabilities: Some(vec!["codex.inline_visualization".to_string()]), opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2231,6 +2232,7 @@ mod tests { "experimentalApi": true, "requestAttestation": true, "mcpServerOpenaiFormElicitation": true, + "hostCapabilities": ["codex.inline_visualization"], "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2258,6 +2260,7 @@ mod tests { "experimentalApi": true, "requestAttestation": true, "mcpServerOpenaiFormElicitation": true, + "hostCapabilities": ["codex.inline_visualization"], "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -2280,6 +2283,7 @@ mod tests { experimental_api: true, request_attestation: true, mcp_server_openai_form_elicitation: true, + host_capabilities: Some(vec!["codex.inline_visualization".to_string()]), opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index dccea51a36..db2648f35d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -53,6 +53,9 @@ pub struct InitializeCapabilities { /// Allow downstream MCP servers to request OpenAI extended form elicitations. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub mcp_server_openai_form_elicitation: bool, + /// Host features available to runtime plugins on this connection. + #[ts(optional = nullable)] + pub host_capabilities: Option>, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 0e32365d12..c75be28b16 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1671,6 +1671,7 @@ impl CodexClient { .collect(), ), mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), }, }; diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6ee6baf2dc..0abe5a5de1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -93,6 +93,19 @@ App-server then advertises the downstream `openai/form` MCP extension for threads started, resumed, or forked by that connection. Clients that cannot handle the request envelope omit the field or set it to `false`. +Clients declare host features available to runtime plugins with +`initialize.params.capabilities.hostCapabilities`. Capability names are opaque +compatibility signals, not authorization grants; omitted or `null` +capabilities are treated as an empty set. The capability set is fixed for the +lifetime of a loaded thread, so clients must use the same set when resuming or +starting turns on that thread. + +Plugin manifests declare all-of host requirements with +`{"requires":{"hostCapabilities":["codex.inline_visualization"]}}`. A plugin +whose requirements are not satisfied remains installed and manageable, but its +skills, MCP servers, apps, hooks, and model-facing summaries are excluded from +that runtime. + Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. **Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index ed040aa8d9..c4949ee544 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -91,6 +91,7 @@ where include_instructions: config.include_skill_instructions, bundled_skills_enabled: config.bundled_skills_enabled(), orchestrator_skills_enabled: config.orchestrator_skills_enabled, + host_capabilities: config.host_capabilities.clone(), }, ); Arc::new(builder.build()) diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d8a2bc2276..114e28c7a3 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -501,13 +501,16 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { match created { Ok(thread_id) => { - let connection_ids = if session.initialized() { - vec![IN_PROCESS_CONNECTION_ID] + let connections = if session.initialized() { + vec![( + IN_PROCESS_CONNECTION_ID, + session.host_capabilities(), + )] } else { - Vec::::new() + Vec::new() }; processor - .try_attach_thread_listener(thread_id, connection_ids) + .try_attach_thread_listener(thread_id, connections) .await; } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 91da0c1645..cb28365246 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1125,16 +1125,19 @@ pub async fn run_main_with_transport_options( created = thread_created_rx.recv(), if listen_for_threads => { match created { Ok(thread_id) => { - let mut initialized_connection_ids = Vec::new(); + let mut initialized_connections = Vec::new(); for (connection_id, connection_state) in &connections { if connection_state.session.initialized() { - initialized_connection_ids.push(*connection_id); + initialized_connections.push(( + *connection_id, + connection_state.session.host_capabilities(), + )); } } processor .try_attach_thread_listener( thread_id, - initialized_connection_ids, + initialized_connections, ) .await; } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 62eff72660..0fd882d1be 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -19,6 +19,7 @@ use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::RequestContext; use crate::request_processors::AccountRequestProcessor; +use crate::request_processors::AppServerClientInfo; use crate::request_processors::AppsRequestProcessor; use crate::request_processors::CatalogRequestProcessor; use crate::request_processors::CommandExecRequestProcessor; @@ -65,6 +66,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::workspace_settings; use codex_core::ThreadManager; use codex_core::config::Config; +use codex_core::config::HostCapabilities; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; @@ -141,6 +143,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) client_version: String, pub(crate) request_attestation: bool, pub(crate) supports_openai_form_elicitation: bool, + pub(crate) host_capabilities: HostCapabilities, } impl Default for ConnectionSessionState { @@ -197,6 +200,13 @@ impl ConnectionSessionState { .get() .is_some_and(|session| session.supports_openai_form_elicitation) } + + pub(crate) fn host_capabilities(&self) -> HostCapabilities { + self.initialized + .get() + .map(|session| session.host_capabilities.clone()) + .unwrap_or_default() + } pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -668,10 +678,10 @@ impl MessageProcessor { pub(crate) async fn try_attach_thread_listener( &self, thread_id: ThreadId, - connection_ids: Vec, + connections: Vec<(ConnectionId, HostCapabilities)>, ) { self.thread_processor - .try_attach_thread_listener(thread_id, connection_ids) + .try_attach_thread_listener(thread_id, connections) .await; } @@ -808,9 +818,12 @@ impl MessageProcessor { ); let serialization_scope = codex_request.serialization_scope(); - let app_server_client_name = session.app_server_client_name().map(str::to_string); - let client_version = session.client_version().map(str::to_string); + let app_server_client_info = AppServerClientInfo { + name: session.app_server_client_name().map(str::to_string), + version: session.client_version().map(str::to_string), + }; let supports_openai_form_elicitation = session.supports_openai_form_elicitation(); + let host_capabilities = session.host_capabilities(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -824,9 +837,9 @@ impl MessageProcessor { connection_request_id, codex_request, request_context, - app_server_client_name, - client_version, + app_server_client_info, supports_openai_form_elicitation, + host_capabilities, ) .await; if let Err(error) = result { @@ -854,9 +867,9 @@ impl MessageProcessor { connection_request_id: ConnectionRequestId, codex_request: ClientRequest, request_context: RequestContext, - app_server_client_name: Option, - client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; let request_id = ConnectionRequestId { @@ -908,7 +921,7 @@ impl MessageProcessor { .remote_control_processor .enable( params.is_some_and(|params| params.ephemeral), - app_server_client_name.as_deref(), + app_server_client_info.name.as_deref(), ) .await .map(|response| Some(response.into())), @@ -916,7 +929,7 @@ impl MessageProcessor { .remote_control_processor .disable( params.is_some_and(|params| params.ephemeral), - app_server_client_name.as_deref(), + app_server_client_info.name.as_deref(), ) .await .map(|response| Some(response.into())), @@ -926,7 +939,7 @@ impl MessageProcessor { .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStart { params, .. } => self .remote_control_processor - .pairing_start(params, app_server_client_name.as_deref()) + .pairing_start(params, app_server_client_info.name.as_deref()) .await .map(|response| Some(response.into())), ClientRequest::RemoteControlPairingStatus { params, .. } => self @@ -1010,9 +1023,9 @@ impl MessageProcessor { .thread_start( request_id.clone(), params, - app_server_client_name.clone(), - client_version.clone(), + app_server_client_info.clone(), supports_openai_form_elicitation, + host_capabilities.clone(), request_context, ) .await @@ -1027,10 +1040,10 @@ impl MessageProcessor { .thread_resume( request_id.clone(), params, - app_server_client_name.clone(), - client_version.clone(), + app_server_client_info.clone(), /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + host_capabilities.clone(), ) .await } @@ -1039,10 +1052,10 @@ impl MessageProcessor { .thread_fork( request_id.clone(), params, - app_server_client_name.clone(), - client_version.clone(), + app_server_client_info.clone(), /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + host_capabilities.clone(), ) .await } @@ -1103,7 +1116,7 @@ impl MessageProcessor { } ClientRequest::ThreadCompactStart { params, .. } => { self.thread_processor - .thread_compact_start(&request_id, params) + .thread_compact_start(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadBackgroundTerminalsClean { params, .. } => { @@ -1123,7 +1136,7 @@ impl MessageProcessor { } ClientRequest::ThreadRollback { params, .. } => { self.thread_processor - .thread_rollback(&request_id, params, app_server_client_name.as_deref()) + .thread_rollback(&request_id, params, app_server_client_info.name.as_deref()) .await } ClientRequest::ThreadList { params, .. } => { @@ -1158,13 +1171,17 @@ impl MessageProcessor { self.thread_processor.conversation_summary(params).await } ClientRequest::SkillsList { params, .. } => { - self.catalog_processor.skills_list(params).await + self.catalog_processor + .skills_list(params, host_capabilities.clone()) + .await } ClientRequest::SkillsExtraRootsSet { params, .. } => { self.catalog_processor.skills_extra_roots_set(params).await } ClientRequest::HooksList { params, .. } => { - self.catalog_processor.hooks_list(params).await + self.catalog_processor + .hooks_list(params, host_capabilities.clone()) + .await } ClientRequest::MarketplaceAdd { params, .. } => { self.marketplace_processor.marketplace_add(params).await @@ -1205,13 +1222,17 @@ impl MessageProcessor { self.plugin_processor.plugin_share_delete(params).await } ClientRequest::AppsList { params, .. } => { - self.apps_processor.apps_list(&request_id, params).await + self.apps_processor + .apps_list(&request_id, params, host_capabilities.clone()) + .await } ClientRequest::SkillsConfigWrite { params, .. } => { self.catalog_processor.skills_config_write(params).await } ClientRequest::PluginInstall { params, .. } => { - self.plugin_processor.plugin_install(params).await + self.plugin_processor + .plugin_install(&request_id, params, host_capabilities.clone()) + .await } ClientRequest::PluginUninstall { params, .. } => { self.plugin_processor.plugin_uninstall(params).await @@ -1240,18 +1261,22 @@ impl MessageProcessor { .turn_start( request_id.clone(), params, - app_server_client_name.clone(), - client_version.clone(), + app_server_client_info, /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + host_capabilities, ) .await } ClientRequest::ThreadInjectItems { params, .. } => { - self.turn_processor.thread_inject_items(params).await + self.turn_processor + .thread_inject_items(params, host_capabilities.clone()) + .await } ClientRequest::TurnSteer { params, .. } => { - self.turn_processor.turn_steer(&request_id, params).await + self.turn_processor + .turn_steer(&request_id, params, host_capabilities.clone()) + .await } ClientRequest::TurnInterrupt { params, .. } => { self.turn_processor @@ -1260,54 +1285,60 @@ impl MessageProcessor { } ClientRequest::ThreadRealtimeStart { params, .. } => { self.turn_processor - .thread_realtime_start(&request_id, params) + .thread_realtime_start(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadRealtimeAppendAudio { params, .. } => { self.turn_processor - .thread_realtime_append_audio(&request_id, params) + .thread_realtime_append_audio(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadRealtimeAppendText { params, .. } => { self.turn_processor - .thread_realtime_append_text(&request_id, params) + .thread_realtime_append_text(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadRealtimeAppendSpeech { params, .. } => { self.turn_processor - .thread_realtime_append_speech(&request_id, params) + .thread_realtime_append_speech(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadRealtimeStop { params, .. } => { self.turn_processor - .thread_realtime_stop(&request_id, params) + .thread_realtime_stop(&request_id, params, host_capabilities.clone()) .await } ClientRequest::ThreadRealtimeListVoices { params: _, .. } => { self.turn_processor.thread_realtime_list_voices().await } ClientRequest::ReviewStart { params, .. } => { - self.turn_processor.review_start(&request_id, params).await + self.turn_processor + .review_start(&request_id, params, host_capabilities.clone()) + .await } ClientRequest::McpServerOauthLogin { params, .. } => { - self.mcp_processor.mcp_server_oauth_login(params).await + self.mcp_processor + .mcp_server_oauth_login(&request_id, params, host_capabilities.clone()) + .await } ClientRequest::McpServerRefresh { params, .. } => { - self.mcp_processor.mcp_server_refresh(params).await + self.mcp_processor + .mcp_server_refresh(params, host_capabilities.clone()) + .await } ClientRequest::McpServerStatusList { params, .. } => { self.mcp_processor - .mcp_server_status_list(&request_id, params) + .mcp_server_status_list(&request_id, params, host_capabilities.clone()) .await } ClientRequest::McpResourceRead { params, .. } => { self.mcp_processor - .mcp_resource_read(&request_id, params) + .mcp_resource_read(&request_id, params, host_capabilities.clone()) .await } ClientRequest::McpServerToolCall { params, .. } => { self.mcp_processor - .mcp_server_tool_call(&request_id, params) + .mcp_server_tool_call(&request_id, params, host_capabilities) .await } ClientRequest::WindowsSandboxSetupStart { params, .. } => { diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index da88d0e067..ad9616d8d1 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -326,6 +326,7 @@ use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config::HostCapabilities; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; @@ -495,6 +496,12 @@ use uuid::Uuid; #[cfg(test)] use codex_app_server_protocol::ServerRequest; +#[derive(Clone, Debug, Default)] +pub(crate) struct AppServerClientInfo { + pub(crate) name: Option, + pub(crate) version: Option, +} + mod account_processor; mod apps_processor; mod catalog_processor; @@ -601,6 +608,21 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec resolved_roots } +async fn ensure_thread_host_capabilities( + thread_id: ThreadId, + thread: &CodexThread, + requested: &HostCapabilities, + operation: &str, +) -> Result<(), JSONRPCErrorError> { + let active = thread.config_snapshot().await.host_capabilities; + if &active != requested { + return Err(invalid_request(format!( + "cannot {operation} on thread {thread_id} with different host capabilities: requested={requested:?} active={active:?}" + ))); + } + Ok(()) +} + mod config_errors; mod request_errors; mod thread_delete; diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 5ff7ec40db..58cf1cd55b 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -36,8 +36,9 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.apps_list_inner(request_id, params) + self.apps_list_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -46,6 +47,7 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let installed_start = Instant::now(); let reload = params.force_refetch; @@ -60,6 +62,7 @@ impl AppsRequestProcessor { None => None, }; let mut config = self.load_latest_config(fallback_cwd).await?; + config.host_capabilities = host_capabilities; if let Some(thread) = thread { let _ = config @@ -131,6 +134,7 @@ impl AppsRequestProcessor { plugins_manager: Arc, installed_start: Instant, ) { + let connection_id = request_id.connection_id; let reload = params.force_refetch; let retry_params = params.clone(); let retry_config = config.clone(); @@ -139,6 +143,7 @@ impl AppsRequestProcessor { let retry_plugins_manager = Arc::clone(&plugins_manager); let result = Self::apps_list_response( &outgoing, + connection_id, params, config, environment_manager, @@ -161,6 +166,7 @@ impl AppsRequestProcessor { retry_params.force_refetch = true; if let Err(err) = Self::apps_list_response( &outgoing, + connection_id, retry_params, retry_config, retry_environment_manager, @@ -176,6 +182,7 @@ impl AppsRequestProcessor { async fn apps_list_response( outgoing: &Arc, + connection_id: ConnectionId, params: AppsListParams, config: Config, environment_manager: Arc, @@ -255,7 +262,7 @@ impl AppsRequestProcessor { accessible_loaded, all_loaded, ) { - send_app_list_updated_notification(outgoing, merged.clone()).await; + send_app_list_updated_notification(outgoing, connection_id, merged.clone()).await; last_notified_apps = Some(merged); } } @@ -315,7 +322,7 @@ impl AppsRequestProcessor { all_loaded, ) && last_notified_apps.as_ref() != Some(&merged) { - send_app_list_updated_notification(outgoing, merged.clone()).await; + send_app_list_updated_notification(outgoing, connection_id, merged.clone()).await; last_notified_apps = Some(merged.clone()); } @@ -444,12 +451,47 @@ fn paginate_apps( async fn send_app_list_updated_notification( outgoing: &Arc, + connection_id: ConnectionId, data: Vec, ) { let data = data.into_iter().map(app_info_to_api).collect(); outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::AppListUpdated(AppListUpdatedNotification { data }), + ) .await; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::outgoing_message::OutgoingEnvelope; + use crate::outgoing_message::OutgoingMessage; + + #[tokio::test] + async fn app_list_updated_notification_targets_requesting_connection() { + let (tx, mut rx) = tokio::sync::mpsc::channel(1); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + + send_app_list_updated_notification(&outgoing, ConnectionId(42), Vec::new()).await; + + let envelope = rx.recv().await.expect("notification should be sent"); + let OutgoingEnvelope::ToConnection { + connection_id, + message, + .. + } = envelope + else { + panic!("app list update should not be broadcast"); + }; + assert_eq!(connection_id, ConnectionId(42)); + assert!(matches!( + message, + OutgoingMessage::AppServerNotification(ServerNotification::AppListUpdated(_)) + )); + } +} diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 6cb4385151..a5e2031c47 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -120,8 +120,9 @@ impl CatalogRequestProcessor { pub(crate) async fn skills_list( &self, params: SkillsListParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.skills_list_response(params) + self.skills_list_response(params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -129,8 +130,9 @@ impl CatalogRequestProcessor { pub(crate) async fn hooks_list( &self, params: HooksListParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.hooks_list_response(params) + self.hooks_list_response(params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -488,6 +490,7 @@ impl CatalogRequestProcessor { async fn skills_list_response( &self, params: SkillsListParams, + host_capabilities: HostCapabilities, ) -> Result { let SkillsListParams { cwds, force_reload } = params; let cwds = if cwds.is_empty() { @@ -496,7 +499,8 @@ impl CatalogRequestProcessor { cwds }; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + config.host_capabilities = host_capabilities; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) @@ -595,6 +599,7 @@ impl CatalogRequestProcessor { async fn hooks_list_response( &self, params: HooksListParams, + host_capabilities: HostCapabilities, ) -> Result { let HooksListParams { cwds } = params; let cwds = if cwds.is_empty() { @@ -611,7 +616,10 @@ impl CatalogRequestProcessor { .config_manager .load_for_cwd( /*request_overrides*/ None, - ConfigOverrides::default(), + ConfigOverrides { + host_capabilities: host_capabilities.clone(), + ..Default::default() + }, Some(cwd.clone()), ) .await diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index cfdad27f50..9e1f8661c0 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -71,6 +71,15 @@ impl InitializeRequestProcessor { let experimental_api_enabled = capabilities.experimental_api; let request_attestation = capabilities.request_attestation; let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation; + let host_capabilities = capabilities + .host_capabilities + .unwrap_or_default() + .into_iter() + .filter_map(|capability| { + let capability = capability.trim(); + (!capability.is_empty()).then(|| capability.to_string()) + }) + .collect(); let opt_out_notification_methods = capabilities .opt_out_notification_methods .unwrap_or_default(); @@ -98,6 +107,7 @@ impl InitializeRequestProcessor { client_version: version, request_attestation, supports_openai_form_elicitation, + host_capabilities, }) .is_err() { diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index ac2125c2cf..62007dcf4c 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -27,9 +27,11 @@ impl McpRequestProcessor { pub(crate) async fn mcp_server_oauth_login( &self, + request_id: &ConnectionRequestId, params: McpServerOauthLoginParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.mcp_server_oauth_login_response(params) + self.mcp_server_oauth_login_response(request_id, params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -37,8 +39,9 @@ impl McpRequestProcessor { pub(crate) async fn mcp_server_refresh( &self, params: Option<()>, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.mcp_server_refresh_response(params) + self.mcp_server_refresh_response(params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -47,8 +50,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.list_mcp_server_status(request_id, params) + self.list_mcp_server_status(request_id, params, host_capabilities) .await .map(|()| None) } @@ -57,8 +61,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpResourceReadParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.read_mcp_resource(request_id, params) + self.read_mcp_resource(request_id, params, host_capabilities) .await .map(|()| None) } @@ -67,8 +72,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpServerToolCallParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.call_mcp_server_tool(request_id, params) + self.call_mcp_server_tool(request_id, params, host_capabilities) .await .map(|()| None) } @@ -76,6 +82,7 @@ impl McpRequestProcessor { async fn mcp_server_refresh_response( &self, _params: Option<()>, + _host_capabilities: HostCapabilities, ) -> Result { crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) .await @@ -111,7 +118,9 @@ impl McpRequestProcessor { async fn mcp_server_oauth_login_response( &self, + request_id: &ConnectionRequestId, params: McpServerOauthLoginParams, + host_capabilities: HostCapabilities, ) -> Result { let McpServerOauthLoginParams { name, @@ -123,12 +132,20 @@ impl McpRequestProcessor { let auth = self.auth_manager.auth().await; let (mcp_config, runtime_context) = match thread_id.as_deref() { Some(thread_id) => { - let (_, thread) = self.load_thread(thread_id).await?; + let (thread_id, thread) = self.load_thread(thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "start MCP OAuth login", + ) + .await?; let runtime = thread.current_mcp_runtime().await; (runtime.config().clone(), runtime.runtime_context().clone()) } None => { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + config.host_capabilities = host_capabilities; let mcp_config = self .thread_manager .mcp_manager() @@ -200,6 +217,7 @@ impl McpRequestProcessor { let authorization_url = handle.authorization_url().to_string(); let notification_name = name.clone(); let notification_thread_id = thread_id; + let notification_connection_id = request_id.connection_id; let outgoing = Arc::clone(&self.outgoing); tokio::spawn(async move { @@ -216,7 +234,12 @@ impl McpRequestProcessor { error, }, ); - outgoing.send_server_notification(notification).await; + outgoing + .send_server_notification_to_connections( + &[notification_connection_id], + notification, + ) + .await; }); Ok(McpServerOauthLoginResponse { authorization_url }) @@ -226,22 +249,35 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); let (config, thread) = match params.thread_id.as_deref() { Some(thread_id) => { - let (_, thread) = self.load_thread(thread_id).await?; + let (thread_id, thread) = self.load_thread(thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "list MCP server status", + ) + .await?; let thread_config = thread.config().await; - let config = self + let mut config = self .config_manager .load_latest_config_for_thread(thread_config.as_ref()) .await .map_err(|err| internal_error(format!("failed to reload config: {err}")))?; + config.host_capabilities = host_capabilities.clone(); (config, Some(thread)) } - None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None), + None => { + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + config.host_capabilities = host_capabilities; + (config, None) + } }; let mcp_manager = self.thread_manager.mcp_manager(); let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache(); @@ -387,6 +423,7 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpResourceReadParams, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let outgoing = Arc::clone(&self.outgoing); let McpResourceReadParams { @@ -396,7 +433,14 @@ impl McpRequestProcessor { } = params; if let Some(thread_id) = thread_id { - let (_, thread) = self.load_thread(&thread_id).await?; + let (thread_id, thread) = self.load_thread(&thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "read an MCP resource", + ) + .await?; let request_id = request_id.clone(); tokio::spawn(async move { @@ -406,7 +450,8 @@ impl McpRequestProcessor { return Ok(()); } - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + config.host_capabilities = host_capabilities; let mcp_manager = self.thread_manager.mcp_manager(); let mcp_config = mcp_manager.runtime_config(&config).await; let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache(); @@ -456,11 +501,19 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: McpServerToolCallParams, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let outgoing = Arc::clone(&self.outgoing); let thread_id = params.thread_id.clone(); - let (_, thread) = self.load_thread(&thread_id).await?; - let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); + let (thread_id, thread) = self.load_thread(&thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "call an MCP tool", + ) + .await?; + let meta = with_mcp_tool_call_thread_id_meta(params.meta, ¶ms.thread_id); let request_id = request_id.clone(); tokio::spawn(async move { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 688cfae917..53c3e74e58 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,6 +23,7 @@ use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; +use codex_plugin::AppConnectorId; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::auth::AuthMode as DomainAuthMode; @@ -88,6 +89,39 @@ fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginI } } +async fn load_plugin_auth_capabilities_for_host( + config: &Config, + plugin_root: &Path, + auth_mode: Option, +) -> (HashMap, Vec) { + if !plugin_auth_side_effects_available_for_host(plugin_root, &config.host_capabilities) { + return Default::default(); + } + + let (mcp_servers, app_declarations) = tokio::join!( + load_plugin_mcp_servers(plugin_root, auth_mode), + load_plugin_apps(plugin_root), + ); + ( + mcp_servers, + codex_plugin::app_connector_ids_from_declarations(&app_declarations), + ) +} + +fn plugin_auth_side_effects_available_for_host( + plugin_root: &Path, + host_capabilities: &HostCapabilities, +) -> bool { + let Some(manifest) = codex_core_plugins::manifest::load_plugin_manifest(plugin_root) else { + return false; + }; + !manifest + .requires + .host_capabilities + .iter() + .any(|capability| !host_capabilities.contains(capability)) +} + fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource { match source { MarketplacePluginSource::Local { path } => PluginSource::Local { path }, @@ -452,9 +486,11 @@ impl PluginRequestProcessor { pub(crate) async fn plugin_install( &self, + request_id: &ConnectionRequestId, params: PluginInstallParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.plugin_install_response(params) + self.plugin_install_response(request_id, params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -1422,7 +1458,9 @@ impl PluginRequestProcessor { async fn plugin_install_response( &self, + request_id: &ConnectionRequestId, params: PluginInstallParams, + host_capabilities: HostCapabilities, ) -> Result { let PluginInstallParams { marketplace_path, @@ -1433,7 +1471,12 @@ impl PluginRequestProcessor { (Some(marketplace_path), None) => marketplace_path, (None, Some(remote_marketplace_name)) => { return self - .remote_plugin_install_response(remote_marketplace_name, plugin_name) + .remote_plugin_install_response( + request_id, + remote_marketplace_name, + plugin_name, + host_capabilities, + ) .await; } (Some(_), Some(_)) | (None, None) => { @@ -1443,7 +1486,8 @@ impl PluginRequestProcessor { } }; let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - let config = self.load_latest_config(config_cwd.clone()).await?; + let mut config = self.load_latest_config(config_cwd.clone()).await?; + config.host_capabilities = host_capabilities.clone(); let auth = self.auth_manager.auth().await; if !self @@ -1477,7 +1521,7 @@ impl PluginRequestProcessor { return Err(Self::plugin_install_error(err)); } }; - let config = match self.load_latest_config(config_cwd).await { + let mut config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { warn!( @@ -1486,22 +1530,25 @@ impl PluginRequestProcessor { config } }; + config.host_capabilities = host_capabilities; self.on_effective_plugins_changed(); - let plugin_mcp_servers = load_plugin_mcp_servers( + let (plugin_mcp_servers, plugin_apps) = load_plugin_auth_capabilities_for_host( + &config, result.installed_path.as_path(), auth.as_ref().map(CodexAuth::auth_mode), ) .await; if !plugin_mcp_servers.is_empty() { - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; + self.start_plugin_mcp_oauth_logins( + request_id.connection_id, + &config, + plugin_mcp_servers, + ) + .await; } - let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await; - let plugin_apps = - codex_plugin::app_connector_ids_from_declarations(&plugin_app_declarations); let apps_needing_auth = self .plugin_apps_needing_auth_for_install( &config, @@ -1519,10 +1566,13 @@ impl PluginRequestProcessor { async fn remote_plugin_install_response( &self, + request_id: &ConnectionRequestId, remote_marketplace_name: String, remote_plugin_id: String, + host_capabilities: HostCapabilities, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + config.host_capabilities = host_capabilities; if !config.features.enabled(Feature::Plugins) { return Err(invalid_request(format!( "remote plugin install is not enabled for marketplace {remote_marketplace_name}" @@ -1666,66 +1716,72 @@ impl PluginRequestProcessor { self.analytics_events_client .track_plugin_installed(plugin_metadata); - let plugin_mcp_servers = load_plugin_mcp_servers( + let auth_side_effects_available = plugin_auth_side_effects_available_for_host( + result.installed_path.as_path(), + &config.host_capabilities, + ); + let (plugin_mcp_servers, plugin_apps) = load_plugin_auth_capabilities_for_host( + &config, result.installed_path.as_path(), auth.as_ref().map(CodexAuth::auth_mode), ) .await; if !plugin_mcp_servers.is_empty() { - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; + self.start_plugin_mcp_oauth_logins( + request_id.connection_id, + &config, + plugin_mcp_servers, + ) + .await; } let is_chatgpt_auth = auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth); - let apps_needing_auth = if let Some(app_ids_needing_auth) = - install_result.app_ids_needing_auth - { - if app_ids_needing_auth.is_empty() - || !config.features.apps_enabled_for_auth(is_chatgpt_auth) - { - Vec::new() + let apps_needing_auth = + if let Some(app_ids_needing_auth) = install_result.app_ids_needing_auth { + if app_ids_needing_auth.is_empty() + || !config.features.apps_enabled_for_auth(is_chatgpt_auth) + || !auth_side_effects_available + { + Vec::new() + } else { + let plugin_apps = app_ids_needing_auth + .into_iter() + .map(AppConnectorId) + .collect::>(); + let app_category_by_id = remote_detail + .app_manifest + .as_ref() + .map(plugin_app_category_by_id_from_value) + .unwrap_or_default(); + let all_connectors = connectors::list_cached_all_connectors(&config, &[]) + .await + .unwrap_or_default(); + connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps) + .into_iter() + .map(|connector| { + let category = app_category_by_id + .get(&connector.id) + .cloned() + .or_else(|| connector.category()); + AppSummary { + category, + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + } + }) + .collect() + } } else { - let plugin_apps = app_ids_needing_auth - .into_iter() - .map(codex_plugin::AppConnectorId) - .collect::>(); - let app_category_by_id = remote_detail - .app_manifest - .as_ref() - .map(plugin_app_category_by_id_from_value) - .unwrap_or_default(); - let all_connectors = connectors::list_cached_all_connectors(&config, &[]) - .await - .unwrap_or_default(); - connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps) - .into_iter() - .map(|connector| { - let category = app_category_by_id - .get(&connector.id) - .cloned() - .or_else(|| connector.category()); - AppSummary { - category, - id: connector.id, - name: connector.name, - description: connector.description, - install_url: connector.install_url, - } - }) - .collect() - } - } else { - let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await; - let plugin_apps = - codex_plugin::app_connector_ids_from_declarations(&plugin_app_declarations); - self.plugin_apps_needing_auth_for_install( - &config, - is_chatgpt_auth, - &result.plugin_id.as_key(), - &plugin_apps, - ) - .await - }; + self.plugin_apps_needing_auth_for_install( + &config, + is_chatgpt_auth, + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await + }; Ok(PluginInstallResponse { auth_policy: remote_detail.summary.auth_policy, @@ -1835,6 +1891,7 @@ impl PluginRequestProcessor { async fn start_plugin_mcp_oauth_logins( &self, + connection_id: ConnectionId, config: &Config, plugin_mcp_servers: HashMap, ) { @@ -1913,7 +1970,9 @@ impl PluginRequestProcessor { error, }, ); - outgoing.send_server_notification(notification).await; + outgoing + .send_server_notification_to_connections(&[connection_id], notification) + .await; }); } } @@ -2398,3 +2457,37 @@ fn remote_plugin_bundle_install_error_to_jsonrpc( ) -> JSONRPCErrorError { internal_error(format!("install remote plugin bundle: {err}")) } + +#[cfg(test)] +mod host_capability_tests { + use super::*; + + #[test] + fn post_install_auth_side_effects_require_host_capabilities() { + let plugin_root = tempfile::tempdir().expect("temp plugin root"); + let manifest_dir = plugin_root.path().join(".codex-plugin"); + std::fs::create_dir_all(&manifest_dir).expect("create manifest directory"); + std::fs::write( + manifest_dir.join("plugin.json"), + r#"{ + "name": "gated-auth-plugin", + "requires": { + "hostCapabilities": ["codex.inline_visualization"] + } + }"#, + ) + .expect("write plugin manifest"); + + let mut host_capabilities = HostCapabilities::new(); + assert!(!plugin_auth_side_effects_available_for_host( + plugin_root.path(), + &host_capabilities, + )); + + host_capabilities.insert("codex.inline_visualization".to_string()); + assert!(plugin_auth_side_effects_available_for_host( + plugin_root.path(), + &host_capabilities, + )); + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index fc6a3281b4..46a9cecd5e 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -451,17 +451,17 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadStartParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, request_context: RequestContext, ) -> Result, JSONRPCErrorError> { self.thread_start_inner( request_id, params, - app_server_client_name, - app_server_client_version, + app_server_client_info, supports_openai_form_elicitation, + host_capabilities, request_context, ) .await @@ -482,16 +482,16 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { self.thread_resume_inner( request_id, params, - app_server_client_name, - app_server_client_version, + app_server_client_info, supports_openai_form_elicitation, + host_capabilities, ) .await .map(|()| None) @@ -501,16 +501,16 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadForkParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { self.thread_fork_inner( request_id, params, - app_server_client_name, - app_server_client_version, + app_server_client_info, supports_openai_form_elicitation, + host_capabilities, ) .await .map(|()| None) @@ -629,8 +629,9 @@ impl ThreadRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadCompactStartParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_compact_start_inner(request_id, params) + self.thread_compact_start_inner(request_id, params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -802,19 +803,13 @@ impl ThreadRequestProcessor { async fn set_app_server_client_info( thread: &CodexThread, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, ) -> Result<(), JSONRPCErrorError> { - let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( - app_server_client_name.as_deref(), - app_server_client_version.as_deref(), - ); + let AppServerClientInfo { name, version } = app_server_client_info; + let mcp_elicitations_auto_deny = + xcode_26_4_mcp_elicitations_auto_deny(name.as_deref(), version.as_deref()); thread - .set_app_server_client_info( - app_server_client_name, - app_server_client_version, - mcp_elicitations_auto_deny, - ) + .set_app_server_client_info(name, version, mcp_elicitations_auto_deny) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } @@ -931,9 +926,9 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadStartParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, request_context: RequestContext, ) -> Result<(), JSONRPCErrorError> { let ThreadStartParams { @@ -985,6 +980,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, ); + typesafe_overrides.host_capabilities = host_capabilities; typesafe_overrides.ephemeral = ephemeral; let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), @@ -1007,8 +1003,7 @@ impl ThreadRequestProcessor { listener_task_context, config_manager, request_id, - app_server_client_name, - app_server_client_version, + app_server_client_info, supports_openai_form_elicitation, config, typesafe_overrides, @@ -1084,8 +1079,7 @@ impl ThreadRequestProcessor { listener_task_context: ListenerTaskContext, config_manager: ConfigManager, request_id: ConnectionRequestId, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, config_overrides: Option>, typesafe_overrides: ConfigOverrides, @@ -1254,12 +1248,7 @@ impl ThreadRequestProcessor { Some("ready"), ); - Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await?; + Self::set_app_server_client_info(thread.as_ref(), app_server_client_info).await?; let instruction_sources = thread.legacy_instruction_sources().await; let config_snapshot = thread @@ -1821,10 +1810,18 @@ impl ThreadRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadCompactStartParams, + host_capabilities: HostCapabilities, ) -> Result { let ThreadCompactStartParams { thread_id } = params; - let (_, thread) = self.load_thread(&thread_id).await?; + let (thread_id, thread) = self.load_thread(&thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "start compaction", + ) + .await?; self.submit_core_op(request_id, thread.as_ref(), Op::Compact) .await .map_err(|err| internal_error(format!("failed to start compaction: {err}")))?; @@ -2627,30 +2624,35 @@ impl ThreadRequestProcessor { pub(crate) async fn try_attach_thread_listener( &self, thread_id: ThreadId, - connection_ids: Vec, + connections: Vec<(ConnectionId, HostCapabilities)>, ) { - let mut raw_events_enabled = false; - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - let config_snapshot = thread.config_snapshot().await; - let loaded_thread = build_thread_from_snapshot( - thread_id, - thread.session_configured().session_id.to_string(), - &config_snapshot, - thread.rollout_path(), - ); - self.thread_watch_manager.upsert_thread(loaded_thread).await; - if let Some(parent_thread_id) = config_snapshot.parent_thread_id { - raw_events_enabled = self - .thread_state_manager - .thread_state(parent_thread_id) - .await - .lock() - .await - .experimental_raw_events; - } - } + let Ok(thread) = self.thread_manager.get_thread(thread_id).await else { + return; + }; + let config_snapshot = thread.config_snapshot().await; + let thread_host_capabilities = config_snapshot.host_capabilities.clone(); + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); + self.thread_watch_manager.upsert_thread(loaded_thread).await; + let raw_events_enabled = if let Some(parent_thread_id) = config_snapshot.parent_thread_id { + self.thread_state_manager + .thread_state(parent_thread_id) + .await + .lock() + .await + .experimental_raw_events + } else { + false + }; - for connection_id in connection_ids { + for (connection_id, host_capabilities) in connections { + if host_capabilities != thread_host_capabilities { + continue; + } log_listener_attach_result( self.ensure_conversation_listener(thread_id, connection_id, raw_events_enabled) .await, @@ -2665,9 +2667,9 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadResumeParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) && self @@ -2697,7 +2699,7 @@ impl ThreadRequestProcessor { return Ok(()); } let redact_resume_payloads = - should_redact_thread_resume_payloads(app_server_client_name.as_deref()); + should_redact_thread_resume_payloads(app_server_client_info.name.as_deref()); let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { Ok(permit) => permit, @@ -2710,8 +2712,8 @@ impl ThreadRequestProcessor { .resume_running_thread( &request_id, ¶ms, - app_server_client_name.clone(), - app_server_client_version.clone(), + &app_server_client_info, + &host_capabilities, ) .await { @@ -2782,6 +2784,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, ); + typesafe_overrides.host_capabilities = host_capabilities; self.load_and_apply_persisted_resume_metadata( &thread_history, &mut request_overrides, @@ -2822,12 +2825,9 @@ impl ThreadRequestProcessor { session_configured, .. }) => { - if let Err(err) = Self::set_app_server_client_info( - codex_thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await + if let Err(err) = + Self::set_app_server_client_info(codex_thread.as_ref(), app_server_client_info) + .await { self.outgoing.send_error(request_id, err).await; return Ok(()); @@ -3008,8 +3008,8 @@ impl ThreadRequestProcessor { &self, request_id: &ConnectionRequestId, params: &ThreadResumeParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: &AppServerClientInfo, + host_capabilities: &HostCapabilities, ) -> Result { let running_thread = if params.history.is_some() { if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) @@ -3069,6 +3069,12 @@ impl ThreadRequestProcessor { ))); } let config_snapshot = existing_thread.config_snapshot().await; + if &config_snapshot.host_capabilities != host_capabilities { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with different host capabilities: requested={host_capabilities:?} active={:?}", + config_snapshot.host_capabilities + ))); + } let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); if !mismatch_details.is_empty() { let has_subscribers = !self @@ -3113,7 +3119,7 @@ impl ThreadRequestProcessor { ); } let redact_resume_payloads = - should_redact_thread_resume_payloads(app_server_client_name.as_deref()); + should_redact_thread_resume_payloads(app_server_client_info.name.as_deref()); let history_items = source_thread .history .take() @@ -3136,8 +3142,7 @@ impl ThreadRequestProcessor { .await?; Self::set_app_server_client_info( existing_thread.as_ref(), - app_server_client_name, - app_server_client_version, + app_server_client_info.clone(), ) .await?; @@ -3435,9 +3440,9 @@ impl ThreadRequestProcessor { &self, request_id: ConnectionRequestId, params: ThreadForkParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let ThreadForkParams { thread_id, @@ -3529,6 +3534,7 @@ impl ThreadRequestProcessor { developer_instructions, /*personality*/ None, ); + typesafe_overrides.host_capabilities = host_capabilities; typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let config = self @@ -3567,12 +3573,7 @@ impl ThreadRequestProcessor { err => internal_error(format!("error forking thread: {err}")), })?; - Self::set_app_server_client_info( - forked_thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await?; + Self::set_app_server_client_info(forked_thread.as_ref(), app_server_client_info).await?; if session_configured.rollout_path.is_some() && let Some(name) = source_thread_name.clone() { diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 8c0d59efed..8da0f306e7 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -783,6 +783,7 @@ mod thread_processor_behavior_tests { parent_thread_id: None, thread_source: None, originator: "test_originator".to_string(), + host_capabilities: Default::default(), }; assert_eq!( diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 3ff64cd34e..69335c34e1 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -156,17 +156,17 @@ impl TurnRequestProcessor { &self, request_id: ConnectionRequestId, params: TurnStartParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { validate_user_input_image_urls(¶ms.input)?; self.turn_start_inner( request_id, params, - app_server_client_name, - app_server_client_version, + app_server_client_info, /*supports_openai_form_elicitation*/ supports_openai_form_elicitation, + host_capabilities, ) .await .map(|response| Some(response.into())) @@ -175,8 +175,9 @@ impl TurnRequestProcessor { pub(crate) async fn thread_inject_items( &self, params: ThreadInjectItemsParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_inject_items_response_inner(params) + self.thread_inject_items_response_inner(params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -195,9 +196,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: TurnSteerParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { validate_user_input_image_urls(¶ms.input)?; - self.turn_steer_inner(request_id, params) + self.turn_steer_inner(request_id, params, host_capabilities) .await .map(|response| Some(response.into())) } @@ -216,8 +218,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStartParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_start_inner(request_id, params) + self.thread_realtime_start_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -226,8 +229,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendAudioParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_append_audio_inner(request_id, params) + self.thread_realtime_append_audio_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -236,8 +240,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendTextParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_append_text_inner(request_id, params) + self.thread_realtime_append_text_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -246,8 +251,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendSpeechParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_append_speech_inner(request_id, params) + self.thread_realtime_append_speech_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -256,8 +262,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStopParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.thread_realtime_stop_inner(request_id, params) + self.thread_realtime_stop_inner(request_id, params, host_capabilities) .await .map(|response| response.map(Into::into)) } @@ -277,8 +284,9 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { - self.review_start_inner(request_id, params) + self.review_start_inner(request_id, params, host_capabilities) .await .map(|()| None) } @@ -443,9 +451,9 @@ impl TurnRequestProcessor { &self, request_id: ConnectionRequestId, params: TurnStartParams, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, supports_openai_form_elicitation: bool, + host_capabilities: HostCapabilities, ) -> Result { let (thread_id, thread) = self.load_thread(¶ms.thread_id) @@ -453,6 +461,13 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(&request_id, error, /*error_type*/ None); })?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "start a turn", + ) + .await?; self.ensure_direct_input_allowed(&request_id, thread.as_ref()) .await?; if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { @@ -463,15 +478,11 @@ impl TurnRequestProcessor { ); return Err(error); } - Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await - .inspect_err(|error| { - self.track_error_response(&request_id, error, /*error_type*/ None); - })?; + Self::set_app_server_client_info(thread.as_ref(), app_server_client_info) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; thread .set_openai_form_elicitation_support(supports_openai_form_elicitation) .await @@ -802,8 +813,16 @@ impl TurnRequestProcessor { async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, + host_capabilities: HostCapabilities, ) -> Result { - let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let (thread_id, thread) = self.load_thread(¶ms.thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "inject items", + ) + .await?; let items = params .items @@ -829,19 +848,13 @@ impl TurnRequestProcessor { async fn set_app_server_client_info( thread: &CodexThread, - app_server_client_name: Option, - app_server_client_version: Option, + app_server_client_info: AppServerClientInfo, ) -> Result<(), JSONRPCErrorError> { - let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( - app_server_client_name.as_deref(), - app_server_client_version.as_deref(), - ); + let AppServerClientInfo { name, version } = app_server_client_info; + let mcp_elicitations_auto_deny = + xcode_26_4_mcp_elicitations_auto_deny(name.as_deref(), version.as_deref()); thread - .set_app_server_client_info( - app_server_client_name, - app_server_client_version, - mcp_elicitations_auto_deny, - ) + .set_app_server_client_info(name, version, mcp_elicitations_auto_deny) .await .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) } @@ -850,13 +863,21 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: TurnSteerParams, + host_capabilities: HostCapabilities, ) -> Result { - let (_, thread) = self - .load_thread(¶ms.thread_id) - .await - .inspect_err(|error| { - self.track_error_response(request_id, error, /*error_type*/ None); - })?; + let (thread_id, thread) = + self.load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(request_id, error, /*error_type*/ None); + })?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + &host_capabilities, + "steer a turn", + ) + .await?; self.ensure_direct_input_allowed(request_id, thread.as_ref()) .await?; @@ -959,8 +980,16 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, thread_id: &str, + host_capabilities: &HostCapabilities, ) -> Result)>, JSONRPCErrorError> { let (thread_id, thread) = self.load_thread(thread_id).await?; + ensure_thread_host_capabilities( + thread_id, + thread.as_ref(), + host_capabilities, + "use realtime", + ) + .await?; match self .ensure_conversation_listener( @@ -990,9 +1019,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStartParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id, &host_capabilities) .await? else { return Ok(None); @@ -1034,9 +1064,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendAudioParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id, &host_capabilities) .await? else { return Ok(None); @@ -1061,9 +1092,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendTextParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id, &host_capabilities) .await? else { return Ok(None); @@ -1089,9 +1121,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeAppendSpeechParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id, &host_capabilities) .await? else { return Ok(None); @@ -1114,9 +1147,10 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ThreadRealtimeStopParams, + host_capabilities: HostCapabilities, ) -> Result, JSONRPCErrorError> { let Some((_, thread)) = self - .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id, &host_capabilities) .await? else { return Ok(None); @@ -1216,7 +1250,7 @@ impl TurnRequestProcessor { )) })?; - let mut config = self.config.as_ref().clone(); + let mut config = parent_thread.config().await.as_ref().clone(); if let Some(review_model) = &config.review_model { config.model = Some(review_model.clone()); } @@ -1309,6 +1343,7 @@ impl TurnRequestProcessor { &self, request_id: &ConnectionRequestId, params: ReviewStartParams, + host_capabilities: HostCapabilities, ) -> Result<(), JSONRPCErrorError> { let ReviewStartParams { thread_id, @@ -1317,6 +1352,13 @@ impl TurnRequestProcessor { } = params; let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; + ensure_thread_host_capabilities( + parent_thread_id, + parent_thread.as_ref(), + &host_capabilities, + "start a review", + ) + .await?; let (review_request, display_text) = Self::review_request_from_target(target)?; match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { CoreReviewDelivery::Inline => { diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index 9df1571627..0c8f53b27e 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -85,6 +85,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand request_attestation: true, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index ece66c8405..2ac7447e47 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -8,6 +8,7 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -17,10 +18,14 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::config::set_project_trust_level; use codex_protocol::config_types::TrustLevel; +use core_test_support::responses; use futures::SinkExt; use futures::StreamExt; use hmac::Hmac; @@ -50,6 +55,9 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::ORIGIN; +use wiremock::Mock; +use wiremock::matchers::method; +use wiremock::matchers::path_regex; // macOS and Windows CI can spend tens of seconds starting the app-server test // binary under Bazel before it accepts JSON-RPC or reports its websocket bind @@ -62,6 +70,12 @@ pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); pub(super) type WsClient = WebSocketStream>; type HmacSha256 = Hmac; +fn body_contains(req: &wiremock::Request, text: &str) -> bool { + String::from_utf8(req.body.clone()) + .ok() + .is_some_and(|body| body.contains(text)) +} + #[tokio::test] async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -107,6 +121,248 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Ok(()) } +#[tokio::test] +async fn host_capabilities_are_normalized_and_sticky_for_loaded_threads() -> Result<()> { + const CHILD_PROMPT: &str = "child: verify capability-scoped auto-attach"; + const PARENT_PROMPT: &str = "spawn a child to test connection auto-attach"; + const SPAWN_CALL_ID: &str = "spawn-call-host-capabilities"; + + let server = responses::start_mock_server().await; + let spawn_args = serde_json::to_string(&json!({ + "message": CHILD_PROMPT, + "task_name": "capability_check", + }))?; + let _parent_turn = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), + responses::sse(vec![ + responses::ev_response_created("resp-parent-1"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "collaboration", + "spawn_agent", + &spawn_args, + ), + responses::ev_completed("resp-parent-1"), + ]), + ) + .await; + let child_response = responses::sse(vec![ + responses::ev_response_created("resp-child-1"), + responses::ev_assistant_message("msg-child-1", "child done"), + responses::ev_completed("resp-child-1"), + ]); + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .and(|req: &wiremock::Request| { + body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID) + }) + .respond_with(responses::sse_response(child_response).set_delay(Duration::from_millis(250))) + .up_to_n_times(1) + .mount(&server) + .await; + let _parent_follow_up = responses::mount_sse_once_match( + &server, + |req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("resp-parent-2"), + responses::ev_assistant_message("msg-parent-2", "parent done"), + responses::ev_completed("resp-parent-2"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!("{config}\n[features]\nmulti_agent_v2 = true\n"), + )?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut owner = connect_websocket(bind_addr).await?; + send_initialize_request_with_host_capabilities( + &mut owner, + /*id*/ 1, + "ws_thread_owner", + Some(vec![" capability-a ", "", "capability-a", "capability-b"]), + ) + .await?; + read_response_for_id(&mut owner, /*id*/ 1) + .await + .context("owner initialize response")?; + + // A connection with a different capability set must not be auto-attached + // when the owner creates the thread. + let mut mismatched_client = connect_websocket(bind_addr).await?; + send_initialize_request_with_host_capabilities( + &mut mismatched_client, + /*id*/ 6, + "ws_mismatched_client", + Some(vec!["capability-c"]), + ) + .await?; + read_response_for_id(&mut mismatched_client, /*id*/ 6) + .await + .context("mismatched-client initialize response")?; + + // Equivalent capabilities with different ordering and without duplicates + // must be eligible for auto-attach after normalization. + let mut equivalent_client = connect_websocket(bind_addr).await?; + send_initialize_request_with_host_capabilities( + &mut equivalent_client, + /*id*/ 4, + "ws_equivalent_client", + Some(vec!["capability-b", "capability-a"]), + ) + .await?; + read_response_for_id(&mut equivalent_client, /*id*/ 4) + .await + .context("equivalent-client initialize response")?; + + let thread_id = start_thread(&mut owner, /*id*/ 2).await?; + read_notification_for_method(&mut mismatched_client, "thread/started") + .await + .context("mismatched-client global thread/started notification")?; + read_notification_for_method(&mut equivalent_client, "thread/started") + .await + .context("equivalent-client global thread/started notification")?; + send_request( + &mut owner, + "turn/start", + /*id*/ 3, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![V2UserInput::Text { + text: PARENT_PROMPT.to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + read_response_for_id(&mut owner, /*id*/ 3) + .await + .context("owner turn/start response")?; + read_notification_for_method(&mut owner, "turn/completed") + .await + .context("owner turn/completed notification")?; + read_notification_for_method(&mut equivalent_client, "turn/completed") + .await + .context("equivalent auto-attached client child turn/completed notification")?; + + match timeout(Duration::from_millis(250), async { + loop { + if let JSONRPCMessage::Notification(notification) = + read_jsonrpc_message(&mut mismatched_client).await? + && notification.method == "turn/completed" + { + return Ok::<_, anyhow::Error>(notification); + } + } + }) + .await + { + Ok(Ok(_)) => bail!("mismatched client received thread-scoped turn/completed"), + Ok(Err(err)) => return Err(err), + Err(_) => {} + } + + send_request( + &mut equivalent_client, + "thread/resume", + /*id*/ 5, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + })?), + ) + .await?; + read_response_for_id(&mut equivalent_client, /*id*/ 5) + .await + .context("equivalent-client resume response")?; + + send_request( + &mut mismatched_client, + "thread/resume", + /*id*/ 7, + Some(serde_json::to_value(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + })?), + ) + .await?; + let resume_error = read_error_for_id(&mut mismatched_client, /*id*/ 7) + .await + .context("mismatched-client resume error")?; + assert!( + resume_error + .error + .message + .contains("cannot resume running thread") + ); + + send_request( + &mut mismatched_client, + "turn/start", + /*id*/ 8, + Some(serde_json::to_value(TurnStartParams { + thread_id: thread_id.clone(), + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + let turn_error = read_error_for_id(&mut mismatched_client, /*id*/ 8) + .await + .context("mismatched-client turn/start error")?; + assert!(turn_error.error.message.contains("cannot start a turn")); + + send_request( + &mut mismatched_client, + "turn/steer", + /*id*/ 9, + Some(json!({ + "threadId": thread_id, + "expectedTurnId": "turn-under-test", + "input": [], + })), + ) + .await?; + let steer_error = read_error_for_id(&mut mismatched_client, /*id*/ 9) + .await + .context("mismatched-client turn/steer error")?; + assert!(steer_error.error.message.contains("cannot steer a turn")); + + send_request( + &mut mismatched_client, + "thread/inject_items", + /*id*/ 10, + Some(json!({ + "threadId": thread_id, + "items": [], + })), + ) + .await?; + let inject_error = read_error_for_id(&mut mismatched_client, /*id*/ 10) + .await + .context("mismatched-client thread/injectItems error")?; + assert!( + inject_error.error.message.contains("cannot inject items"), + "unexpected inject error: {}", + inject_error.error.message + ); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + #[tokio::test] async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -694,6 +950,15 @@ pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, client_name: &str, +) -> Result<()> { + send_initialize_request_with_host_capabilities(stream, id, client_name, None).await +} + +async fn send_initialize_request_with_host_capabilities( + stream: &mut WsClient, + id: i64, + client_name: &str, + host_capabilities: Option>, ) -> Result<()> { let params = InitializeParams { client_info: ClientInfo { @@ -701,7 +966,11 @@ pub(super) async fn send_initialize_request( title: Some("WebSocket Test Client".to_string()), version: "0.1.0".to_string(), }, - capabilities: None, + capabilities: host_capabilities.map(|host_capabilities| InitializeCapabilities { + experimental_api: true, + host_capabilities: Some(host_capabilities.into_iter().map(str::to_string).collect()), + ..Default::default() + }), }; send_request( stream, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index cc010b93eb..cfbb4679fa 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -44,6 +44,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -80,6 +81,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -131,6 +133,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -170,6 +173,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -209,6 +213,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -263,6 +268,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -305,6 +311,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; @@ -346,6 +353,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index aba3f56078..8df1033cf4 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -232,6 +232,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ), ) diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index dad1db88af..5f283fa515 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -155,6 +155,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), mcp_server_openai_form_elicitation: false, + host_capabilities: None, }), ), ) diff --git a/codex-rs/core-plugins/src/discoverable.rs b/codex-rs/core-plugins/src/discoverable.rs index eb1df6ff52..516f011be0 100644 --- a/codex-rs/core-plugins/src/discoverable.rs +++ b/codex-rs/core-plugins/src/discoverable.rs @@ -116,10 +116,11 @@ impl PluginsManager { &marketplace_name, &plugin, &skill_config_rules, + &input.plugins.host_capabilities, ) .await { - Ok(plugin) => { + Ok(Some(plugin)) => { discoverable_plugins.push(ToolSuggestDiscoverablePlugin { id: plugin.config_name, remote_plugin_id: None, @@ -134,6 +135,7 @@ impl PluginsManager { .collect(), }); } + Ok(None) => {} Err(err) => { warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}") } diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 48482ed430..d1dbb945e4 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -34,6 +34,9 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; +const HOST_CAPABILITY_INLINE_VISUALIZATION: &str = "codex.inline_visualization"; +const HOST_CAPABILITY_SESSION_OWNED_IAB: &str = "browser.session_owned_iab"; + #[tokio::test] async fn returns_fallback_plugins_when_remote_disabled_for_codex_auth() { let codex_home = tempdir().expect("tempdir should succeed"); @@ -290,6 +293,63 @@ source = "/tmp/{marketplace_name}" ); } +#[tokio::test] +async fn reprojects_cached_configured_and_fallback_plugins_for_host_capabilities() { + let codex_home = tempdir().expect("tempdir should succeed"); + let curated_root = curated_plugins_repo_path(codex_home.path()); + write_openai_curated_marketplace(&curated_root, &["sample", "slack"]); + let write_required_manifests = |required_capability: &str| { + for plugin_name in ["sample", "slack"] { + write_file( + &curated_root.join(format!("plugins/{plugin_name}/.codex-plugin/plugin.json")), + &format!( + r#"{{ + "name": "{plugin_name}", + "description": "Plugin that includes skills, MCP servers, and app connectors", + "requires": {{ + "hostCapabilities": ["{required_capability}"] + }} +}}"# + ), + ); + } + }; + write_required_manifests(HOST_CAPABILITY_INLINE_VISUALIZATION); + + let mut plugins = load_plugins_config(codex_home.path(), codex_home.path()).await; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let configured_plugin_id = "sample@openai-curated"; + + let unsupported = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins.clone(), &[configured_plugin_id], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!(unsupported, Vec::new()); + + write_required_manifests(HOST_CAPABILITY_SESSION_OWNED_IAB); + plugins + .host_capabilities + .insert(HOST_CAPABILITY_INLINE_VISUALIZATION.to_string()); + let supported = list_discoverable_plugins( + &plugins_manager, + discovery_input(plugins, &[configured_plugin_id], &[], &[]), + /*auth*/ None, + ) + .await; + assert_eq!( + supported + .into_iter() + .map(|plugin| plugin.id) + .collect::>(), + vec![ + "sample@openai-curated".to_string(), + "slack@openai-curated".to_string(), + ] + ); +} + #[tokio::test] async fn reprojects_cached_skill_availability_for_current_config() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 2b5c4782e8..49405a4ec8 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -178,8 +178,9 @@ pub async fn load_plugin_hooks_from_layer_stack( extra_plugins: HashMap, store: &PluginStore, remote_global_catalog_active: bool, + host_capabilities: &HashSet, ) -> PluginHookLoadOutcome { - let plugins = load_plugins_from_layer_stack_with_scope( + let mut plugins = load_plugins_from_layer_stack_with_scope( config_layer_stack, extra_plugins, store, @@ -187,6 +188,9 @@ pub async fn load_plugin_hooks_from_layer_stack( PluginLoadScope::HooksOnly, ) .await; + for plugin in &mut plugins { + plugin.apply_host_capabilities(host_capabilities); + } PluginHookLoadOutcome { hook_sources: plugins .iter() @@ -764,6 +768,8 @@ async fn load_plugin( manifest_description: None, root, enabled: plugin.enabled, + required_host_capabilities: Vec::new(), + missing_host_capabilities: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, @@ -804,6 +810,7 @@ async fn load_plugin( let manifest_paths = &manifest.paths; loaded_plugin.plugin_namespace = Some(manifest.name.clone()); + loaded_plugin.required_host_capabilities = manifest.requires.host_capabilities.clone(); match scope { PluginLoadScope::AllCapabilities { restriction_product, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 16fb4c8663..9d2669e74e 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -110,6 +110,7 @@ pub struct PluginsConfigInput { pub plugins_enabled: bool, pub remote_plugin_enabled: bool, pub chatgpt_base_url: String, + pub host_capabilities: HashSet, } impl PluginsConfigInput { @@ -124,6 +125,7 @@ impl PluginsConfigInput { plugins_enabled, remote_plugin_enabled, chatgpt_base_url, + host_capabilities: HashSet::new(), } } } @@ -361,7 +363,8 @@ pub struct PluginsManager { RwLock>>>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - // Keep the cache auth-independent so auth changes only need to resolve capabilities again. + // Keep the cache auth- and host-capability-independent so runtime changes only need to + // resolve capabilities again. loaded_plugins_cache: RwLock, loaded_plugins_load_semaphore: Semaphore, tool_suggest_metadata_cache: ToolSuggestMetadataCache, @@ -554,7 +557,7 @@ impl PluginsManager { remote_global_catalog_active, ); if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { - return self.resolve_loaded_plugins_for_auth(plugins); + return self.resolve_loaded_plugins_for_runtime(plugins, &config.host_capabilities); } let Ok(_load_permit) = self.loaded_plugins_load_semaphore.acquire().await else { @@ -562,7 +565,7 @@ impl PluginsManager { return PluginLoadOutcome::default(); }; if !force_reload && let Some(plugins) = self.cached_loaded_plugins(&cache_key) { - return self.resolve_loaded_plugins_for_auth(plugins); + return self.resolve_loaded_plugins_for_runtime(plugins, &config.host_capabilities); } let cache_generation = self.loaded_plugins_cache_generation(); let plugin_skill_snapshots = PluginSkillSnapshots::for_plugin_load(); @@ -582,12 +585,17 @@ impl PluginsManager { plugins.clone(), plugin_skill_snapshots, ); - self.resolve_loaded_plugins_for_auth(plugins) + self.resolve_loaded_plugins_for_runtime(plugins, &config.host_capabilities) } - fn resolve_loaded_plugins_for_auth(&self, mut plugins: Vec) -> PluginLoadOutcome { + fn resolve_loaded_plugins_for_runtime( + &self, + mut plugins: Vec, + host_capabilities: &HashSet, + ) -> PluginLoadOutcome { let auth_mode = self.auth_mode(); for plugin in &mut plugins { + plugin.apply_host_capabilities(host_capabilities); let plugin_active = plugin.is_active(); apply_app_mcp_routing_policy( &mut plugin.apps, @@ -660,7 +668,7 @@ impl PluginsManager { self.remote_global_catalog_active(config), ) .await; - self.resolve_loaded_plugins_for_auth(plugins) + self.resolve_loaded_plugins_for_runtime(plugins, &config.host_capabilities) } /// Resolve plugin hooks for a config layer stack without loading other plugin capabilities. @@ -677,6 +685,7 @@ impl PluginsManager { self.remote_installed_plugin_configs(), &self.store, self.remote_global_catalog_active(config), + &config.host_capabilities, ) .await } @@ -1683,12 +1692,13 @@ impl PluginsManager { marketplace_name: &str, plugin: &ConfiguredMarketplacePlugin, skill_config_rules: &SkillConfigRules, - ) -> Result { + host_capabilities: &HashSet, + ) -> Result, MarketplaceError> { let fragment = self .tool_suggest_metadata_cache .metadata_for_plugin(marketplace_name, plugin, self.restriction_product) .await?; - Ok(fragment.project(skill_config_rules, self.auth_mode())) + Ok(fragment.project(skill_config_rules, self.auth_mode(), host_capabilities)) } pub async fn read_plugin_for_config( diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 5e743416f2..3a2a12fc92 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -66,6 +66,8 @@ use wiremock::matchers::path; use wiremock::matchers::query_param; const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; +const HOST_CAPABILITY_INLINE_VISUALIZATION: &str = "codex.inline_visualization"; +const HOST_CAPABILITY_SESSION_OWNED_IAB: &str = "browser.session_owned_iab"; fn unrestricted_config_layer_stack() -> ConfigLayerStack { ConfigLayerStack::default() @@ -746,6 +748,72 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { toml::to_string(&Value::Table(root)).expect("plugin test config should serialize") } +fn write_host_capability_projection_plugin(codex_home: &Path) -> std::path::PathBuf { + let plugin_root = codex_home.join("plugins/cache/test/sample/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "sample", + "requires": { + "hostCapabilities": [ + "codex.inline_visualization", + "browser.session_owned_iab" + ] + } +}"#, + ); + write_file( + &plugin_root.join("skills/sample/SKILL.md"), + "---\nname: sample\ndescription: sample skill\n---\n", + ); + write_file( + &plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample-mcp": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "sample-app": { + "id": "connector_sample" + } + } +}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ] + } +}"#, + ); + plugin_root +} + +fn set_host_capabilities(config: &mut PluginsConfigInput, capabilities: &[&str]) { + config.host_capabilities = capabilities + .iter() + .map(|capability| (*capability).to_string()) + .collect(); +} + async fn load_plugins_from_config( config_toml: &str, codex_home: &Path, @@ -864,6 +932,8 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ), root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, + required_host_capabilities: Vec::new(), + missing_host_capabilities: Vec::new(), skill_roots: vec![plugin_root.join("skills").abs()], disabled_skill_paths: HashSet::new(), has_enabled_skills: true, @@ -923,6 +993,145 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ); } +#[tokio::test] +async fn plugin_host_capability_projection_is_all_of_and_reprojects_cached_plugins() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = write_host_capability_projection_plugin(codex_home.path()); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + let mut config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new_with_options( + codex_home.path().to_path_buf(), + Some(Product::Codex), + Some(AuthMode::Chatgpt), + ); + + let unavailable = manager.plugins_for_config(&config).await; + let assert_unavailable = |outcome: &PluginLoadOutcome, missing_host_capabilities: &[&str]| { + let plugin = &outcome.plugins()[0]; + assert!(plugin.enabled); + assert_eq!(plugin.error, None); + assert_eq!( + plugin.missing_host_capabilities, + missing_host_capabilities + .iter() + .map(|capability| (*capability).to_string()) + .collect::>() + ); + assert_eq!( + plugin.required_host_capabilities, + vec![ + HOST_CAPABILITY_INLINE_VISUALIZATION.to_string(), + HOST_CAPABILITY_SESSION_OWNED_IAB.to_string(), + ] + ); + assert_eq!(plugin.skill_roots, vec![plugin_root.join("skills").abs()]); + assert_eq!( + plugin.apps, + vec![app_declaration("sample-app", "connector_sample")] + ); + assert_eq!(plugin.hook_sources.len(), 1); + assert_eq!( + plugin.mcp_servers.keys().cloned().collect::>(), + HashSet::from(["sample-mcp".to_string()]) + ); + assert!(outcome.effective_skill_roots().is_empty()); + assert!(outcome.effective_mcp_servers().is_empty()); + assert!(outcome.effective_apps().is_empty()); + assert!(outcome.effective_plugin_hook_sources().is_empty()); + assert!(outcome.capability_summaries().is_empty()); + }; + assert_unavailable( + &unavailable, + &[ + HOST_CAPABILITY_INLINE_VISUALIZATION, + HOST_CAPABILITY_SESSION_OWNED_IAB, + ], + ); + + fs::remove_dir_all(&plugin_root).expect("remove plugin after raw load is cached"); + set_host_capabilities(&mut config, &[HOST_CAPABILITY_INLINE_VISUALIZATION]); + let partially_supported = manager.plugins_for_config(&config).await; + assert_unavailable(&partially_supported, &[HOST_CAPABILITY_SESSION_OWNED_IAB]); + + set_host_capabilities( + &mut config, + &[ + HOST_CAPABILITY_INLINE_VISUALIZATION, + HOST_CAPABILITY_SESSION_OWNED_IAB, + ], + ); + let supported = manager.plugins_for_config(&config).await; + assert!(supported.plugins()[0].missing_host_capabilities.is_empty()); + assert!(supported.plugins()[0].is_active()); + assert_eq!( + supported.effective_skill_roots(), + vec![plugin_root.join("skills").abs()] + ); + assert_eq!( + sorted_effective_mcp_server_names(&supported), + vec!["sample-mcp".to_string()] + ); + assert_eq!( + supported.effective_apps(), + vec![AppConnectorId("connector_sample".to_string())] + ); + assert_eq!(supported.effective_plugin_hook_sources().len(), 1); + assert_eq!( + supported.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample-mcp".to_string()], + app_connector_ids: vec![AppConnectorId("connector_sample".to_string())], + }] + ); +} + +#[tokio::test] +async fn plugin_without_host_capability_requirements_remains_active() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home.path().join("plugins/cache/test/sample/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("skills/sample/SKILL.md"), + "---\nname: sample\ndescription: sample skill\n---\n", + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + /*auth_mode*/ None, + ) + .await; + + assert!(outcome.plugins()[0].required_host_capabilities.is_empty()); + assert!(outcome.plugins()[0].missing_host_capabilities.is_empty()); + assert!(outcome.plugins()[0].is_active()); + assert_eq!( + outcome.effective_skill_roots(), + vec![plugin_root.join("skills").abs()] + ); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }] + ); +} + #[tokio::test] async fn load_plugins_loads_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); @@ -1906,6 +2115,7 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { version: None, description: None, keywords: Vec::new(), + requires: crate::manifest::PluginManifestRequirements::default(), paths: crate::manifest::PluginManifestPaths { skills: vec![ plugin_root.join("skills"), @@ -2145,6 +2355,8 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, + required_host_capabilities: Vec::new(), + missing_host_capabilities: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, @@ -2320,6 +2532,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, + required_host_capabilities: Vec::new(), + missing_host_capabilities: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, @@ -5874,3 +6088,37 @@ async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() { ); assert_eq!(outcome.hook_load_warnings, Vec::::new()); } + +#[tokio::test] +async fn plugin_hooks_for_layer_stack_applies_host_capability_requirements() { + let codex_home = TempDir::new().unwrap(); + write_host_capability_projection_plugin(codex_home.path()); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + ); + let mut config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let unavailable = manager + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + assert_eq!(unavailable, PluginHookLoadOutcome::default()); + + set_host_capabilities( + &mut config, + &[ + HOST_CAPABILITY_INLINE_VISUALIZATION, + HOST_CAPABILITY_SESSION_OWNED_IAB, + ], + ); + let supported = manager + .plugin_hooks_for_layer_stack(&config.config_layer_stack, &config) + .await; + assert_eq!(supported.hook_sources.len(), 1); + assert_eq!( + supported.hook_sources[0].source_relative_path, + "hooks/hooks.json" + ); + assert_eq!(supported.hook_load_warnings, Vec::::new()); +} diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 0edebe3699..6e1bf529d5 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -16,6 +16,7 @@ pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterfa pub type PluginManifestMcpServers = codex_plugin::manifest::PluginManifestMcpServers; pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths; +pub type PluginManifestRequirements = codex_plugin::manifest::PluginManifestRequirements; pub(crate) type UriPluginManifest = codex_plugin::manifest::PluginManifest; @@ -30,6 +31,8 @@ struct RawPluginManifest { description: Option, #[serde(default)] keywords: Vec, + #[serde(default)] + requires: RawPluginManifestRequirements, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] @@ -44,6 +47,13 @@ struct RawPluginManifest { interface: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestRequirements { + #[serde(default)] + host_capabilities: Vec, +} + #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct RawPluginManifestInterface { @@ -162,6 +172,7 @@ pub(crate) fn parse_plugin_manifest_uri( version, description, keywords, + requires, skills, mcp_servers, apps, @@ -177,6 +188,17 @@ pub(crate) fn parse_plugin_manifest_uri( let version = version.trim(); (!version.is_empty()).then(|| version.to_string()) }); + let mut host_capabilities = Vec::new(); + for capability in requires.host_capabilities { + let capability = capability.trim(); + if !capability.is_empty() + && !host_capabilities + .iter() + .any(|existing| existing == capability) + { + host_capabilities.push(capability.to_string()); + } + } let interface = interface.and_then(|interface| { let RawPluginManifestInterface { display_name, @@ -257,6 +279,7 @@ pub(crate) fn parse_plugin_manifest_uri( version, description, keywords, + requires: codex_plugin::manifest::PluginManifestRequirements { host_capabilities }, paths: codex_plugin::manifest::PluginManifestPaths { skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), @@ -519,6 +542,7 @@ mod tests { use codex_plugin::manifest::PluginManifestInterface; use codex_plugin::manifest::PluginManifestMcpServers; use codex_plugin::manifest::PluginManifestPaths; + use codex_plugin::manifest::PluginManifestRequirements; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_utils_absolute_path::AbsolutePathBuf; @@ -705,6 +729,42 @@ mod tests { ); } + #[test] + fn plugin_manifest_normalizes_required_host_capabilities() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "requires": { + "hostCapabilities": [ + " codex.inline_visualization ", + "", + "codex.code_mode", + "codex.inline_visualization", + " codex.code_mode ", + " " + ] + } +}"#, + ) + .expect("write manifest"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.requires, + PluginManifestRequirements { + host_capabilities: vec![ + "codex.inline_visualization".to_string(), + "codex.code_mode".to_string(), + ], + } + ); + } + #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); @@ -714,6 +774,9 @@ mod tests { r#"{ "name": "demo-plugin", "version": " 2.0.0 ", + "requires": { + "hostCapabilities": ["codex.inline_visualization"] + }, "interface": { "displayName": "Fallback Plugin" } @@ -723,6 +786,10 @@ mod tests { let manifest = load_manifest(&plugin_root); assert_eq!(manifest.version, Some("2.0.0".to_string())); + assert_eq!( + manifest.requires.host_capabilities, + vec!["codex.inline_visualization".to_string()] + ); assert_eq!( manifest .interface @@ -773,17 +840,25 @@ mod tests { } #[tokio::test] - async fn host_and_executor_sources_parse_the_same_manifest() { + async fn host_and_executor_sources_parse_the_same_manifest_with_requirements() { let temp_dir = tempdir().expect("tempdir"); let plugin_root = temp_dir.path().join("demo-plugin"); - write_manifest( - &plugin_root, - Some(" 1.2.3 "), + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), r#"{ + "name": "demo-plugin", + "version": " 1.2.3 ", + "requires": { + "hostCapabilities": ["codex.inline_visualization"] + }, + "interface": { "displayName": "Demo Plugin", "composerIcon": "./assets/icon.svg" - }"#, - ); + } +}"#, + ) + .expect("write manifest"); let plugin_root = AbsolutePathBuf::from_absolute_path_checked(plugin_root).expect("absolute plugin root"); let plugin_root_uri = PathUri::from_abs_path(&plugin_root); @@ -819,6 +894,12 @@ mod tests { ) .expect("valid expected descriptor"); + assert_eq!( + executor_plugin.manifest().requires, + PluginManifestRequirements { + host_capabilities: vec!["codex.inline_visualization".to_string()], + } + ); assert_eq!(executor_plugin, expected_plugin); } @@ -853,6 +934,7 @@ mod tests { version: None, description: None, keywords: Vec::new(), + requires: PluginManifestRequirements::default(), paths: PluginManifestPaths { skills: vec![plugin_root.join("skills").expect("skills URI")], mcp_servers: Some(PluginManifestMcpServers::Path( diff --git a/codex-rs/core-plugins/src/tool_suggest_metadata.rs b/codex-rs/core-plugins/src/tool_suggest_metadata.rs index 30065e6d94..b232adde41 100644 --- a/codex-rs/core-plugins/src/tool_suggest_metadata.rs +++ b/codex-rs/core-plugins/src/tool_suggest_metadata.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::sync::Arc; use std::sync::RwLock; @@ -30,8 +31,9 @@ type ToolSuggestMetadataEntry = Result, String> /// Source-derived plugin metadata cached for tool suggestions. /// -/// `PluginsManager` clears these entries alongside its loaded-plugin cache. Current skill config -/// and auth routing are projected after each lookup and are not part of this cache. +/// `PluginsManager` clears these entries alongside its loaded-plugin cache. Current skill +/// configuration, runtime host capabilities, and auth routing are projected after each lookup and +/// are not part of this cache. pub(crate) struct ToolSuggestMetadataCache { state: RwLock, load_semaphore: Semaphore, @@ -53,6 +55,7 @@ pub(crate) struct ToolSuggestMetadataFragment { config_name: String, display_name: String, description: Option, + required_host_capabilities: Vec, mcp_server_names: Vec, app_declarations: Vec, skill_inventory: Option, @@ -63,7 +66,16 @@ impl ToolSuggestMetadataFragment { &self, skill_config_rules: &SkillConfigRules, auth_mode: Option, - ) -> PluginCapabilitySummary { + host_capabilities: &HashSet, + ) -> Option { + if self + .required_host_capabilities + .iter() + .any(|capability| !host_capabilities.contains(capability)) + { + return None; + } + let mut app_declarations = self.app_declarations.clone(); let mut mcp_servers = self .mcp_server_names @@ -82,7 +94,7 @@ impl ToolSuggestMetadataFragment { let mut mcp_server_names = mcp_servers.into_keys().collect::>(); mcp_server_names.sort_unstable(); - PluginCapabilitySummary { + Some(PluginCapabilitySummary { config_name: self.config_name.clone(), display_name: self.display_name.clone(), description: self.description.clone(), @@ -92,7 +104,7 @@ impl ToolSuggestMetadataFragment { .is_some_and(|inventory| inventory.has_enabled_skills(skill_config_rules)), mcp_server_names, app_connector_ids: app_connector_ids_from_declarations(&app_declarations), - } + }) } } @@ -200,6 +212,7 @@ async fn load_plugin_metadata( description: prompt_safe_plugin_description(Some( &remote_plugin_install_required_description(&plugin.source), )), + required_host_capabilities: Vec::new(), mcp_server_names: Vec::new(), app_declarations: Vec::new(), skill_inventory: None, @@ -231,6 +244,7 @@ async fn load_plugin_metadata( config_name: plugin.id.clone(), display_name: plugin.name.clone(), description: prompt_safe_plugin_description(manifest.description.as_deref()), + required_host_capabilities: manifest.requires.host_capabilities, mcp_server_names, app_declarations, skill_inventory: Some(skill_inventory), diff --git a/codex-rs/core-skills/src/service.rs b/codex-rs/core-skills/src/service.rs index b4e0d7396c..b68fcf09d4 100644 --- a/codex-rs/core-skills/src/service.rs +++ b/codex-rs/core-skills/src/service.rs @@ -69,7 +69,7 @@ pub struct SkillsService { codex_home: AbsolutePathBuf, restriction_product: Option, extra_roots: RwLock>, - cache_by_cwd: RwLock>, + cache_by_cwd: RwLock>, cache_by_config: RwLock>, } @@ -173,9 +173,17 @@ impl SkillsService { fs: Option>, ) -> HostSkillsSnapshot { let use_cwd_cache = fs.is_some(); + let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); + let bundled_skills_enabled = bundled_skills_enabled_from_stack(&input.config_layer_stack); + let cache_key = CwdSkillsCacheKey { + cwd: input.cwd.clone(), + effective_skill_roots: input.effective_skill_roots.clone(), + bundled_skills_enabled, + skill_config_rules: skill_config_rules.clone(), + }; if use_cwd_cache && !force_reload - && let Some(snapshot) = self.cached_snapshot_for_cwd(&input.cwd) + && let Some(snapshot) = self.cached_snapshot_for_cwd(&cache_key) { return snapshot; } @@ -188,10 +196,9 @@ impl SkillsService { self.extra_roots(), ) .await; - if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { + if !bundled_skills_enabled { roots.retain(|root| root.scope != SkillScope::System); } - let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let snapshot = HostSkillsSnapshot::new(Arc::new( self.build_skill_outcome(input, roots, &skill_config_rules) .await, @@ -201,7 +208,7 @@ impl SkillsService { .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(input.cwd.clone(), snapshot.clone()); + cache.insert(cache_key, snapshot.clone()); } snapshot } @@ -243,10 +250,10 @@ impl SkillsService { info!("skills cache cleared ({cleared} entries)"); } - fn cached_snapshot_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option { + fn cached_snapshot_for_cwd(&self, cache_key: &CwdSkillsCacheKey) -> Option { match self.cache_by_cwd.read() { - Ok(cache) => cache.get(cwd).cloned(), - Err(err) => err.into_inner().get(cwd).cloned(), + Ok(cache) => cache.get(cache_key).cloned(), + Err(err) => err.into_inner().get(cache_key).cloned(), } } @@ -268,6 +275,14 @@ impl SkillsService { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct CwdSkillsCacheKey { + cwd: AbsolutePathBuf, + effective_skill_roots: Vec, + bundled_skills_enabled: bool, + skill_config_rules: SkillConfigRules, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { roots: Vec<(AbsolutePathBuf, u8, Option, Option)>, diff --git a/codex-rs/core-skills/src/service_tests.rs b/codex-rs/core-skills/src/service_tests.rs index 6026975097..98ad36a6d3 100644 --- a/codex-rs/core-skills/src/service_tests.rs +++ b/codex-rs/core-skills/src/service_tests.rs @@ -228,6 +228,68 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn cwd_cache_separates_effective_plugin_skill_roots() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_service = SkillsService::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + let skill_path = write_plugin_skill( + &codex_home, + "test", + "visualize", + "visualize", + "visualize", + "render inline visualizations", + ); + let plugin_root = plugin_skill_root_for_skill_path(&skill_path, "visualize@test", "visualize"); + + let supported_input = SkillsLoadInput::new( + cwd.path().abs(), + vec![plugin_root], + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let supported = skills_service + .snapshot_for_cwd( + &supported_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + supported + .outcome() + .skills + .iter() + .any(|skill| skill.name == "visualize:visualize") + ); + + let unsupported_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let unsupported = skills_service + .snapshot_for_cwd( + &unsupported_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + unsupported + .outcome() + .skills + .iter() + .all(|skill| skill.name != "visualize:visualize") + ); +} + #[tokio::test] async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index b34aa8aa21..88e656f730 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -205,6 +205,7 @@ mod reload { ) -> ConfigOverrides { ConfigOverrides { cwd: Some(config.cwd.to_path_buf()), + host_capabilities: config.host_capabilities.clone(), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index b56531c90c..99df15d8ab 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -181,6 +181,7 @@ async fn apply_role_preserves_unspecified_keys() { .await; config.codex_linux_sandbox_exe = Some(PathBuf::from("/tmp/codex-linux-sandbox")); config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper")); + config.host_capabilities = ["codex.inline_visualization".to_string()].into(); let role_path = write_role_config( &home, "effort-only.toml", @@ -202,6 +203,10 @@ async fn apply_role_preserves_unspecified_keys() { assert_eq!(config.model.as_deref(), Some("base-model")); assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + config.host_capabilities, + ["codex.inline_visualization".to_string()].into() + ); assert_eq!( config.codex_linux_sandbox_exe, Some(PathBuf::from("/tmp/codex-linux-sandbox")) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 257a32733b..a15dfd26f7 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -55,6 +55,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::watch; +use crate::config::HostCapabilities; use codex_rollout::state_db::StateDbHandle; #[derive(Clone, Debug)] @@ -80,6 +81,7 @@ pub struct ThreadConfigSnapshot { pub parent_thread_id: Option, pub thread_source: Option, pub originator: String, + pub host_capabilities: HostCapabilities, } /// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1df2bb3e3f..a575c94baa 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -4741,7 +4741,12 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{ + "name": "sample", + "requires": { + "hostCapabilities": ["codex.inline_visualization"] + } +}"#, )?; std::fs::write( plugin_root.join(".mcp.json"), @@ -4808,6 +4813,7 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() thread_layer_stack.effective_config().try_into()?, ConfigOverrides { cwd: Some(codex_home.path().to_path_buf()), + host_capabilities: ["codex.inline_visualization".to_string()].into(), ..Default::default() }, codex_home.abs(), @@ -4821,6 +4827,10 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() let mcp_config = config.to_mcp_config(&plugins_manager).await; let configured_servers = mcp_config.mcp_server_catalog.configured_servers(); + assert_eq!( + config.host_capabilities, + ["codex.inline_visualization".to_string()].into() + ); assert_eq!( configured_servers.get("sample"), Some(&http_mcp("https://sample.example/mcp")) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5cd6748c05..01cba62a54 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -120,6 +120,7 @@ use rmcp::model::UrlElicitationCapability; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::collections::HashMap; use std::collections::HashSet; use std::io::ErrorKind; @@ -610,6 +611,9 @@ pub enum ThreadStoreConfig { InMemory { id: String }, } +/// Opaque host features available to runtime plugins for one thread. +pub type HostCapabilities = BTreeSet; + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -620,6 +624,12 @@ pub struct Config { /// Warnings collected during config load that should be shown on startup. pub startup_warnings: Vec, + /// Host features available to runtime plugins for this thread. + /// + /// This is supplied by the embedding client rather than config.toml and is + /// intentionally not persisted with the thread. + pub host_capabilities: HostCapabilities, + /// Optional override of model selection. pub model: Option, @@ -1498,12 +1508,14 @@ impl Config { /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { - PluginsConfigInput::new( + let mut input = PluginsConfigInput::new( self.config_layer_stack.clone(), self.features.enabled(Feature::Plugins), self.features.enabled(Feature::RemotePlugin), self.chatgpt_base_url.clone(), - ) + ); + input.host_capabilities = self.host_capabilities.iter().cloned().collect(); + input } /// Applies managed MCP requirements to servers supplied by one plugin. @@ -1677,6 +1689,7 @@ impl Config { ConfigOverrides { cwd: Some(self.cwd.to_path_buf()), default_zsh_path, + host_capabilities: self.host_capabilities.clone(), ..Default::default() }, refreshed_config.codex_home.clone(), @@ -2426,6 +2439,8 @@ pub struct ConfigOverrides { /// Explicit absolute runtime workspace roots for this session. When set, /// this is the full runtime root list rather than an additive override. pub workspace_roots: Option>, + /// Opaque host features supplied by the embedding client for this thread. + pub host_capabilities: HostCapabilities, } fn dedupe_absolute_paths(paths: &mut Vec) { @@ -3016,6 +3031,7 @@ impl Config { bypass_hook_trust, additional_writable_roots, workspace_roots: workspace_roots_override, + host_capabilities, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); @@ -3799,6 +3815,7 @@ impl Config { workspace_roots: workspace_roots.clone(), workspace_roots_explicit, startup_warnings, + host_capabilities, permissions: Permissions { approval_policy: constrained_approval_policy.value, permission_profile_state, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index a35b717cad..73ed28341a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -50,6 +50,7 @@ struct AccessibleConnectorsCacheKey { account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, + host_capabilities: crate::config::HostCapabilities, } #[derive(Clone)] @@ -359,6 +360,7 @@ fn accessible_connectors_cache_key( account_id, chatgpt_user_id, is_workspace_account, + host_capabilities: config.host_capabilities.clone(), } } diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index fb28539048..a1db021110 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -222,6 +222,12 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal .expect("config should load"); let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true); let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None); + let mut other_host_config = config.clone(); + other_host_config + .host_capabilities + .insert("codex.inline_visualization".to_string()); + let other_host_cache_key = + accessible_connectors_cache_key(&other_host_config, /*auth*/ None); let tools = vec![ codex_app_tool( "calendar_list_events", @@ -239,6 +245,10 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal let cached = with_accessible_connectors_cache_cleared(|| { refresh_accessible_connectors_cache_from_mcp_tools(&config, /*auth*/ None, &tools); + assert_eq!( + read_cached_accessible_connectors(&other_host_cache_key), + None + ); read_cached_accessible_connectors(&cache_key).expect("cache should be populated") }); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 7c59ec22b7..35106d5253 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -199,6 +199,7 @@ impl SessionConfiguration { parent_thread_id: self.parent_thread_id, thread_source: self.thread_source.clone(), originator: self.originator.clone(), + host_capabilities: self.original_config_do_not_use.host_capabilities.clone(), } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 1d9fd2e56d..f7063c035a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -446,6 +446,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ephemeral: ephemeral.then_some(true), bypass_hook_trust: bypass_hook_trust.then_some(true), additional_writable_roots: add_dir, + host_capabilities: Default::default(), }; let build_config = |overrides| { @@ -573,6 +574,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; diff --git a/codex-rs/ext/mcp/src/executor_plugin.rs b/codex-rs/ext/mcp/src/executor_plugin.rs index 98cfcd3615..7e3e8c0028 100644 --- a/codex-rs/ext/mcp/src/executor_plugin.rs +++ b/codex-rs/ext/mcp/src/executor_plugin.rs @@ -24,6 +24,7 @@ mod provider; struct SelectedPluginMetadata { plugin_id: String, plugin_display_name: String, + required_host_capabilities: Vec, servers: Vec<(String, codex_config::McpServerConfig)>, connector_ids: Vec, } @@ -112,6 +113,12 @@ impl SelectedExecutorPluginMcpContributor { Some(SelectedPluginMetadata { plugin_id: plugin.plugin().selected_root_id().to_string(), plugin_display_name: plugin.plugin().manifest().display_name().to_string(), + required_host_capabilities: plugin + .plugin() + .manifest() + .requires + .host_capabilities + .clone(), servers, connector_ids, }) @@ -171,6 +178,13 @@ impl McpServerContributor for SelectedExecutorPluginMcpContributor { let Some(plugin) = self.metadata_for_root(&state, selected_root).await else { continue; }; + if !plugin + .required_host_capabilities + .iter() + .all(|capability| context.config().host_capabilities.contains(capability)) + { + continue; + } let mut servers = plugin.servers.iter().cloned().collect::>(); context .config() diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index bc59e104a0..2bc4a27592 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -17,6 +17,7 @@ use codex_plugin::ResolvedPlugin; use codex_plugin::manifest::PluginManifest; use codex_plugin::manifest::PluginManifestMcpServers; use codex_plugin::manifest::PluginManifestPaths; +use codex_plugin::manifest::PluginManifestRequirements; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; @@ -400,6 +401,7 @@ fn resolved_plugin( version: None, description: None, keywords: Vec::new(), + requires: PluginManifestRequirements::default(), paths: PluginManifestPaths { skills: Vec::new(), mcp_servers, diff --git a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs index afb6b0b923..5616337271 100644 --- a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs +++ b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs @@ -147,6 +147,104 @@ async fn selected_plugin_package_is_contributed_without_servers_or_connectors() Ok(()) } +#[tokio::test] +async fn selected_plugin_contributions_require_all_host_capabilities_after_caching() -> TestResult { + let codex_home = tempfile::tempdir()?; + let plugin_root = tempfile::tempdir()?; + std::fs::create_dir_all(plugin_root.path().join(".codex-plugin"))?; + std::fs::write( + plugin_root.path().join(".codex-plugin/plugin.json"), + r#"{ + "name": "gated-plugin", + "requires": { + "hostCapabilities": ["codex.inline_visualization", "codex.code_mode"] + }, + "apps": "./apps.json", + "interface": {"displayName": "Gated Plugin"} +}"#, + )?; + std::fs::write( + plugin_root.path().join(".mcp.json"), + r#"{"mcpServers":{"gated":{"command":"gated-command"}}}"#, + )?; + std::fs::write( + plugin_root.path().join("apps.json"), + r#"{"apps":{"demo":{"id":"connector_demo"}}}"#, + )?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + let mut builder = ExtensionRegistryBuilder::new(); + codex_mcp_extension::install_executor_plugins( + &mut builder, + Arc::new(EnvironmentManager::default_for_tests()), + ); + let registry = builder.build(); + let mut thread_init = ExtensionDataInit::new(); + thread_init.insert(vec![SelectedCapabilityRoot { + id: "selected-root".to_string(), + location: CapabilityRootLocation::Environment { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + path: PathUri::from_host_native_path(plugin_root.path())?, + }, + }]); + let thread_store = ExtensionData::new_with_init("test-thread", thread_init.clone()); + let available_environment_ids = vec![LOCAL_ENVIRONMENT_ID.to_string()]; + let contributor = ®istry.mcp_server_contributors()[0]; + + let unsupported = contributor + .contribute(McpServerContributionContext::for_step( + &config, + &thread_init, + &thread_store, + "test_originator", + &available_environment_ids, + )) + .await; + assert!(unsupported.is_empty()); + + config.host_capabilities = ["codex.inline_visualization".to_string()].into(); + let partially_supported = contributor + .contribute(McpServerContributionContext::for_step( + &config, + &thread_init, + &thread_store, + "test_originator", + &available_environment_ids, + )) + .await; + assert!(partially_supported.is_empty()); + + config.host_capabilities = [ + "codex.code_mode".to_string(), + "codex.inline_visualization".to_string(), + ] + .into(); + let supported = contributor + .contribute(McpServerContributionContext::for_step( + &config, + &thread_init, + &thread_store, + "test_originator", + &available_environment_ids, + )) + .await; + assert_eq!(supported.len(), 2); + assert!(matches!( + &supported[0], + McpServerContribution::SelectedPlugin { name, .. } if name == "gated" + )); + assert!(matches!( + &supported[1], + McpServerContribution::SelectedPluginPackage { connector_ids, .. } + if connector_ids.as_slice() == ["connector_demo"] + )); + + Ok(()) +} + async fn selected_plugin_contributions( config: &Config, plugin_root: &std::path::Path, diff --git a/codex-rs/ext/skills/Cargo.toml b/codex-rs/ext/skills/Cargo.toml index 35827ac578..5256e63e45 100644 --- a/codex-rs/ext/skills/Cargo.toml +++ b/codex-rs/ext/skills/Cargo.toml @@ -15,6 +15,7 @@ workspace = true [dependencies] codex-core-skills = { workspace = true } +codex-core-plugins = { workspace = true } codex-exec-server = { workspace = true } codex-extension-api = { workspace = true } codex-mcp = { workspace = true } diff --git a/codex-rs/ext/skills/src/catalog.rs b/codex-rs/ext/skills/src/catalog.rs index 59977a47ca..d666537289 100644 --- a/codex-rs/ext/skills/src/catalog.rs +++ b/codex-rs/ext/skills/src/catalog.rs @@ -1,5 +1,6 @@ use codex_core_skills::model::SkillDependencies; use codex_utils_path_uri::PathUri; +use std::collections::BTreeSet; /// Source authority that owns a skill package and must be used to read it. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -117,6 +118,7 @@ pub struct SkillCatalogEntry { pub dependencies: Option, pub enabled: bool, pub prompt_visible: bool, + pub(crate) required_host_capabilities: Vec, } impl SkillCatalogEntry { @@ -138,6 +140,7 @@ impl SkillCatalogEntry { dependencies: None, enabled: true, prompt_visible: true, + required_host_capabilities: Vec::new(), } } @@ -166,6 +169,21 @@ impl SkillCatalogEntry { self } + pub fn with_required_host_capabilities( + mut self, + required_host_capabilities: Vec, + ) -> Self { + self.required_host_capabilities = required_host_capabilities; + self + } + + /// Returns whether every host capability required by this package is available. + pub fn host_requirements_satisfied_by(&self, host_capabilities: &BTreeSet) -> bool { + self.required_host_capabilities + .iter() + .all(|capability| host_capabilities.contains(capability)) + } + pub(crate) fn rendered_path(&self) -> &str { self.display_path .as_deref() @@ -178,6 +196,9 @@ impl SkillCatalogEntry { pub struct SkillCatalog { pub entries: Vec, pub warnings: Vec, + /// Warnings whose visibility depends on the host capabilities available at projection time. + #[doc(hidden)] + pub host_capability_gated_warnings: Vec, } impl SkillCatalog { @@ -186,6 +207,8 @@ impl SkillCatalog { self.push_entry(entry); } self.warnings.extend(other.warnings); + self.host_capability_gated_warnings + .extend(other.host_capability_gated_warnings); } pub fn push_entry(&mut self, entry: SkillCatalogEntry) { @@ -199,6 +222,33 @@ impl SkillCatalog { self.entries.push(entry); } + + pub(crate) fn extend_warnings_with_host_requirements( + &mut self, + warnings: Vec, + required_host_capabilities: &[String], + ) { + if warnings.is_empty() { + return; + } + if required_host_capabilities.is_empty() { + self.warnings.extend(warnings); + } else { + self.host_capability_gated_warnings + .push(HostCapabilityGatedWarnings { + required_host_capabilities: required_host_capabilities.to_vec(), + warnings, + }); + } + } +} + +/// Warning group carried in raw catalogs until host capabilities are projected. +#[doc(hidden)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostCapabilityGatedWarnings { + pub required_host_capabilities: Vec, + pub warnings: Vec, } /// Contents returned after resolving a skill resource through its owner. diff --git a/codex-rs/ext/skills/src/config.rs b/codex-rs/ext/skills/src/config.rs index 3d903cafb8..517e7b1342 100644 --- a/codex-rs/ext/skills/src/config.rs +++ b/codex-rs/ext/skills/src/config.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + /// Host-supplied configuration used by the skills extension. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SkillsExtensionConfig { @@ -7,4 +9,6 @@ pub struct SkillsExtensionConfig { pub bundled_skills_enabled: bool, /// Whether orchestrator-owned skills are eligible for discovery. pub orchestrator_skills_enabled: bool, + /// Opaque host features available to runtime plugins for this thread. + pub host_capabilities: BTreeSet, } diff --git a/codex-rs/ext/skills/src/extension.rs b/codex-rs/ext/skills/src/extension.rs index a632ab6824..fed24ed0fa 100644 --- a/codex-rs/ext/skills/src/extension.rs +++ b/codex-rs/ext/skills/src/extension.rs @@ -123,6 +123,7 @@ where include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), mcp_resources: session_store.get::(), + host_capabilities: config.host_capabilities.clone(), }, &thread_state, ) @@ -160,6 +161,7 @@ where include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: false, mcp_resources: input.session_store.get::(), + host_capabilities: config.host_capabilities.clone(), }, ) .await; @@ -231,6 +233,7 @@ where include_bundled_skills: config.bundled_skills_enabled, include_orchestrator_skills: thread_state.orchestrator_skills_enabled(), mcp_resources: session_store.get::(), + host_capabilities: config.host_capabilities.clone(), }; let mut catalog = self.list_skills(query, &thread_state).await; if let Some(executor_skills) = turn_store.get::() { diff --git a/codex-rs/ext/skills/src/provider.rs b/codex-rs/ext/skills/src/provider.rs index 405592e934..0ccd68b653 100644 --- a/codex-rs/ext/skills/src/provider.rs +++ b/codex-rs/ext/skills/src/provider.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -31,6 +32,7 @@ pub struct SkillListQuery { pub include_bundled_skills: bool, pub include_orchestrator_skills: bool, pub mcp_resources: Option>, + pub host_capabilities: BTreeSet, } #[derive(Clone, Debug)] diff --git a/codex-rs/ext/skills/src/provider/executor.rs b/codex-rs/ext/skills/src/provider/executor.rs index 9938035e92..ce9925aa19 100644 --- a/codex-rs/ext/skills/src/provider/executor.rs +++ b/codex-rs/ext/skills/src/provider/executor.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use codex_core_plugins::ExecutorPluginProvider; use codex_core_skills::loader::EnvironmentSkillMetadata; use codex_core_skills::loader::load_environment_skills_from_root; use codex_exec_server::EnvironmentManager; @@ -26,6 +27,7 @@ use crate::provider::SkillSearchRequest; #[derive(Clone, Debug)] pub struct ExecutorSkillProvider { environment_manager: Arc, + plugin_provider: ExecutorPluginProvider, restriction_product: Option, } @@ -35,6 +37,7 @@ impl ExecutorSkillProvider { restriction_product: Option, ) -> Self { Self { + plugin_provider: ExecutorPluginProvider::new(Arc::clone(&environment_manager)), environment_manager, restriction_product, } @@ -46,7 +49,26 @@ impl SkillProvider for ExecutorSkillProvider { Box::pin(async move { let mut catalog = SkillCatalog::default(); for selected_root in query.executor_roots { - let selected_root_id = selected_root.id; + let selected_root_id = selected_root.id.clone(); + let required_host_capabilities = match self + .plugin_provider + .resolve_bound(&selected_root) + .await + { + Ok(Some(plugin)) => plugin + .plugin() + .manifest() + .requires + .host_capabilities + .clone(), + Ok(None) => Vec::new(), + Err(err) => { + catalog.warnings.push(format!( + "Selected capability root `{selected_root_id}` could not be resolved as a plugin: {err}" + )); + continue; + } + }; let CapabilityRootLocation::Environment { environment_id, path, @@ -55,9 +77,12 @@ impl SkillProvider for ExecutorSkillProvider { SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone()); let Some(environment) = self.environment_manager.get_environment(&environment_id) else { - catalog.warnings.push(format!( - "Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`." - )); + catalog.extend_warnings_with_host_requirements( + vec![format!( + "Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`." + )], + &required_host_capabilities, + ); continue; }; let file_system = environment.get_filesystem(); @@ -67,13 +92,17 @@ impl SkillProvider for ExecutorSkillProvider { self.restriction_product, ) .await; - catalog.warnings.extend(outcome.warnings); + catalog.extend_warnings_with_host_requirements( + outcome.warnings, + &required_host_capabilities, + ); for skill in outcome.skills { catalog.push_entry(catalog_entry_from_skill( &skill, authority.clone(), &selected_root_id, &environment_id, + &required_host_capabilities, )); } } @@ -133,6 +162,7 @@ fn catalog_entry_from_skill( authority: SkillAuthority, selected_root_id: &str, environment_id: &str, + required_host_capabilities: &[String], ) -> SkillCatalogEntry { let skill_path = skill.path_to_skills_md.inferred_native_path_string(); let normalized_path = match skill.path_to_skills_md.infer_path_convention() { @@ -156,7 +186,8 @@ fn catalog_entry_from_skill( ) .with_short_description(skill.short_description.clone()) .with_display_path(display_path) - .with_dependencies(skill.dependencies.clone()); + .with_dependencies(skill.dependencies.clone()) + .with_required_host_capabilities(required_host_capabilities.to_vec()); if skill.allows_implicit_invocation() { entry diff --git a/codex-rs/ext/skills/src/provider/host.rs b/codex-rs/ext/skills/src/provider/host.rs index 2cee694216..a662b22996 100644 --- a/codex-rs/ext/skills/src/provider/host.rs +++ b/codex-rs/ext/skills/src/provider/host.rs @@ -95,6 +95,7 @@ fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog { ) }) .collect(), + ..Default::default() }; for (skill, enabled) in outcome.skills_with_enabled() { diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index 2094f34496..c87ee50fcf 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -78,13 +78,26 @@ impl SkillsThreadState { mut query: SkillListQuery, ) -> SkillCatalog { let roots = std::mem::take(&mut query.executor_roots); + let host_capabilities = query.host_capabilities.clone(); let mut catalog = SkillCatalog::default(); for root in roots { query.executor_roots = vec![root.clone()]; - catalog.extend( - self.executor_root_catalog(providers, root, query.clone()) - .await, - ); + let mut root_catalog = self + .executor_root_catalog(providers, root, query.clone()) + .await; + root_catalog + .entries + .retain(|entry| entry.host_requirements_satisfied_by(&host_capabilities)); + for gated_warnings in std::mem::take(&mut root_catalog.host_capability_gated_warnings) { + if gated_warnings + .required_host_capabilities + .iter() + .all(|capability| host_capabilities.contains(capability)) + { + root_catalog.warnings.extend(gated_warnings.warnings); + } + } + catalog.extend(root_catalog); } catalog } diff --git a/codex-rs/ext/skills/src/tools/mod.rs b/codex-rs/ext/skills/src/tools/mod.rs index 0d05064afb..ae32131614 100644 --- a/codex-rs/ext/skills/src/tools/mod.rs +++ b/codex-rs/ext/skills/src/tools/mod.rs @@ -73,6 +73,7 @@ impl SkillToolContext { include_bundled_skills: false, include_orchestrator_skills: true, mcp_resources: self.mcp_resources.clone(), + host_capabilities: self.thread_state.config().host_capabilities, }), ) .await diff --git a/codex-rs/ext/skills/tests/executor_file_system_authority.rs b/codex-rs/ext/skills/tests/executor_file_system_authority.rs index 06bf062eac..ceaf9af1ad 100644 --- a/codex-rs/ext/skills/tests/executor_file_system_authority.rs +++ b/codex-rs/ext/skills/tests/executor_file_system_authority.rs @@ -265,6 +265,7 @@ async fn selected_root_id_distinguishes_identical_executor_paths() { include_bundled_skills: true, include_orchestrator_skills: false, mcp_resources: None, + host_capabilities: Default::default(), }) .await .expect("list executor skills"); @@ -299,6 +300,79 @@ async fn selected_root_id_distinguishes_identical_executor_paths() { std::fs::remove_dir_all(test_root).expect("remove skill directory"); } +#[tokio::test] +async fn executor_plugin_skills_preserve_all_host_capability_requirements() { + let test_root = create_local_skill_root("host-requirements").expect("create local skill root"); + std::fs::create_dir_all(test_root.join(".codex-plugin")).expect("create manifest directory"); + std::fs::write( + test_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "gated-skill-plugin", + "requires": { + "hostCapabilities": ["codex.inline_visualization", "codex.code_mode"] + } +}"#, + ) + .expect("write plugin manifest"); + std::fs::create_dir_all(test_root.join("broken")).expect("create broken skill directory"); + std::fs::write(test_root.join("broken/SKILL.md"), "missing frontmatter") + .expect("write broken skill"); + let provider = ExecutorSkillProvider::new_with_restriction_product( + Arc::new(EnvironmentManager::default_for_tests()), + /*restriction_product*/ None, + ); + let catalog = provider + .list(SkillListQuery { + turn_id: "turn-1".to_string(), + executor_roots: vec![SelectedCapabilityRoot { + id: "selected-root".to_string(), + location: CapabilityRootLocation::Environment { + environment_id: "local".to_string(), + path: PathUri::from_host_native_path(&test_root).expect("skill root URI"), + }, + }], + host_snapshot: None, + include_host_skills: false, + include_bundled_skills: true, + include_orchestrator_skills: false, + mcp_resources: None, + host_capabilities: Default::default(), + }) + .await + .expect("list executor skills"); + let [entry] = catalog.entries.as_slice() else { + panic!("expected one executor skill"); + }; + + assert!(catalog.warnings.is_empty()); + let [gated_warnings] = catalog.host_capability_gated_warnings.as_slice() else { + panic!("expected one host-gated warning group"); + }; + assert_eq!( + gated_warnings.required_host_capabilities, + vec![ + "codex.inline_visualization".to_string(), + "codex.code_mode".to_string(), + ] + ); + assert_eq!(gated_warnings.warnings.len(), 1); + assert!(!entry.host_requirements_satisfied_by(&Default::default())); + assert!( + !entry.host_requirements_satisfied_by(&["codex.inline_visualization".to_string()].into()) + ); + assert!( + entry.host_requirements_satisfied_by( + &[ + "codex.code_mode".to_string(), + "codex.inline_visualization".to_string(), + ] + .into() + ) + ); + + std::fs::remove_dir_all(test_root).expect("remove skill directory"); +} + fn create_local_skill_root(label: &str) -> io::Result { let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed); let test_root = std::env::temp_dir().join(format!( diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 76d0df7954..7107ae9134 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -158,6 +158,7 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach "lint-fix/SKILL.md", )], warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::clone(&read_requests), list_calls: Some(Arc::clone(&list_calls)), @@ -335,6 +336,196 @@ async fn selected_executor_catalog_follows_step_availability_and_reuses_its_cach Ok(()) } +#[tokio::test] +async fn selected_executor_skills_require_all_host_capabilities_after_caching() -> TestResult { + let list_calls = Arc::new(AtomicUsize::new(0)); + let executor_provider = Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![ + test_entry( + SkillSourceKind::Executor, + "env-1", + "executor/visualize", + "visualize/SKILL.md", + ) + .with_required_host_capabilities(vec![ + "codex.inline_visualization".to_string(), + "codex.code_mode".to_string(), + ]), + ], + warnings: Vec::new(), + host_capability_gated_warnings: vec![ + codex_skills_extension::catalog::HostCapabilityGatedWarnings { + required_host_capabilities: vec![ + "codex.inline_visualization".to_string(), + "codex.code_mode".to_string(), + ], + warnings: vec!["gated plugin skill warning".to_string()], + }, + ], + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: Some(Arc::clone(&list_calls)), + fail_first_list: false, + }); + let providers = SkillProviders::new().with_executor_provider(executor_provider); + let (event_tx, event_rx) = std::sync::mpsc::channel(); + let mut builder = + ExtensionRegistryBuilder::with_event_sink(Arc::new(ChannelEventSink(event_tx))); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let selected_roots = vec![SelectedCapabilityRoot { + id: "visualize".to_string(), + location: CapabilityRootLocation::Environment { + environment_id: "env-1".to_string(), + path: PathUri::parse("file:///skills/visualize").expect("skill root URI"), + }, + }]; + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + let turn_environment = TurnEnvironmentSelection { + environment_id: "env-1".to_string(), + cwd: PathUri::parse("file:///workspace").expect("cwd URI"), + }; + + let unsupported_turn_store = ExtensionData::new("turn-1"); + let unsupported = registry.context_contributors()[0] + .contribute_world_state(WorldStateContributionInput { + thread_id: codex_protocol::ThreadId::new(), + turn_id: "turn-1", + environments: std::slice::from_ref(&turn_environment), + ready_selected_capability_roots: &selected_roots, + session_store: &session_store, + thread_store: &thread_store, + turn_store: &unsupported_turn_store, + }) + .await; + assert!( + unsupported[0] + .render_diff(PreviousWorldStateSection::Absent) + .is_none() + ); + assert!(unsupported[0].snapshot()["body"].is_null()); + let unsupported_fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-1".to_string(), + user_input: Vec::new(), + environments: Vec::new(), + }, + &session_store, + &thread_store, + &unsupported_turn_store, + ) + .await; + assert!(unsupported_fragments.is_empty()); + assert!(matches!( + event_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let mut partial_config = config.clone(); + partial_config.host_capabilities = ["codex.inline_visualization".to_string()].into(); + registry.config_contributors()[0].on_config_changed( + &session_store, + &thread_store, + &config, + &partial_config, + ); + let partial_turn_store = ExtensionData::new("turn-2"); + let partially_supported = registry.context_contributors()[0] + .contribute_world_state(WorldStateContributionInput { + thread_id: codex_protocol::ThreadId::new(), + turn_id: "turn-2", + environments: std::slice::from_ref(&turn_environment), + ready_selected_capability_roots: &selected_roots, + session_store: &session_store, + thread_store: &thread_store, + turn_store: &partial_turn_store, + }) + .await; + assert!(partially_supported[0].snapshot()["body"].is_null()); + let partially_supported_fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-2".to_string(), + user_input: Vec::new(), + environments: Vec::new(), + }, + &session_store, + &thread_store, + &partial_turn_store, + ) + .await; + assert!(partially_supported_fragments.is_empty()); + assert!(matches!( + event_rx.try_recv(), + Err(std::sync::mpsc::TryRecvError::Empty) + )); + + let mut supported_config = partial_config.clone(); + supported_config.host_capabilities = [ + "codex.code_mode".to_string(), + "codex.inline_visualization".to_string(), + ] + .into(); + registry.config_contributors()[0].on_config_changed( + &session_store, + &thread_store, + &partial_config, + &supported_config, + ); + let supported_turn_store = ExtensionData::new("turn-3"); + let supported = registry.context_contributors()[0] + .contribute_world_state(WorldStateContributionInput { + thread_id: codex_protocol::ThreadId::new(), + turn_id: "turn-3", + environments: &[turn_environment], + ready_selected_capability_roots: &selected_roots, + session_store: &session_store, + thread_store: &thread_store, + turn_store: &supported_turn_store, + }) + .await; + assert!( + supported[0].snapshot()["body"] + .as_str() + .is_some_and(|body| body.contains("visualize")) + ); + let supported_fragments = registry.turn_input_contributors()[0] + .contribute( + TurnInputContext { + turn_id: "turn-3".to_string(), + user_input: Vec::new(), + environments: Vec::new(), + }, + &session_store, + &thread_store, + &supported_turn_store, + ) + .await; + assert!(supported_fragments.is_empty()); + let EventMsg::Warning(warning) = event_rx.try_recv()?.msg else { + panic!("expected gated warning for supported plugin"); + }; + assert_eq!(warning.message, "gated plugin skill warning"); + assert_eq!(1, list_calls.load(Ordering::Relaxed)); + + Ok(()) +} + #[tokio::test] async fn default_context_truncates_catalog_descriptions() -> TestResult { let description = "x".repeat(1_025); @@ -350,6 +541,7 @@ async fn default_context_truncates_catalog_descriptions() -> TestResult { catalog: SkillCatalog { entries: vec![entry], warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::new(Mutex::new(Vec::new())), list_calls: None, @@ -400,6 +592,7 @@ async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResu catalog: SkillCatalog { entries: vec![entry], warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::new(Mutex::new(Vec::new())), list_calls: None, @@ -470,6 +663,7 @@ async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { "skill://orchestrator/first/SKILL.md", )], warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::new(Mutex::new(Vec::new())), list_calls: Some(Arc::clone(&list_calls)), @@ -551,6 +745,7 @@ async fn root_qualified_locator_selects_only_the_matching_executor_skill() -> Te }) .collect(), warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::clone(&read_requests), list_calls: None, @@ -651,6 +846,7 @@ async fn prompt_hidden_skill_can_still_be_invoked() -> TestResult { .hidden_from_prompt(), ], warnings: Vec::new(), + ..Default::default() }, read_requests: Arc::clone(&read_requests), list_calls: None, @@ -781,6 +977,7 @@ struct TestConfig { include_instructions: bool, bundled_skills_enabled: bool, orchestrator_skills_enabled: bool, + host_capabilities: std::collections::BTreeSet, } fn default_config() -> TestConfig { @@ -788,6 +985,7 @@ fn default_config() -> TestConfig { include_instructions: true, bundled_skills_enabled: true, orchestrator_skills_enabled: true, + host_capabilities: Default::default(), } } @@ -796,6 +994,7 @@ fn skills_extension_config(config: &TestConfig) -> SkillsExtensionConfig { include_instructions: config.include_instructions, bundled_skills_enabled: config.bundled_skills_enabled, orchestrator_skills_enabled: config.orchestrator_skills_enabled, + host_capabilities: config.host_capabilities.clone(), } } diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index ad83655463..24700e3dcc 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -21,6 +21,8 @@ pub struct LoadedPlugin { pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, + pub required_host_capabilities: Vec, + pub missing_host_capabilities: Vec, pub skill_roots: Vec, pub disabled_skill_paths: HashSet, pub has_enabled_skills: bool, @@ -33,12 +35,21 @@ pub struct LoadedPlugin { impl LoadedPlugin { pub fn is_active(&self) -> bool { - self.enabled && self.error.is_none() + self.enabled && self.error.is_none() && self.missing_host_capabilities.is_empty() } pub fn display_name(&self) -> &str { self.manifest_name.as_deref().unwrap_or(&self.config_name) } + + pub fn apply_host_capabilities(&mut self, host_capabilities: &HashSet) { + self.missing_host_capabilities = self + .required_host_capabilities + .iter() + .filter(|capability| !host_capabilities.contains(*capability)) + .cloned() + .collect::>(); + } } fn plugin_capability_summary_from_loaded( @@ -232,6 +243,8 @@ mod tests { manifest_description: None, root: test_path(config_name), enabled: true, + required_host_capabilities: Vec::new(), + missing_host_capabilities: Vec::new(), skill_roots, disabled_skill_paths: HashSet::new(), has_enabled_skills: true, diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 89cfd8d0b6..544901564c 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -10,10 +10,18 @@ pub struct PluginManifest { pub version: Option, pub description: Option, pub keywords: Vec, + pub requires: PluginManifestRequirements, pub paths: PluginManifestPaths, pub interface: Option>, } +/// Host capabilities required for a plugin to contribute runtime resources. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginManifestRequirements { + /// Every listed capability must be advertised by the host. + pub host_capabilities: Vec, +} + /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { @@ -100,6 +108,7 @@ impl PluginManifest { version, description, keywords, + requires, paths, interface, } = self; @@ -176,6 +185,7 @@ impl PluginManifest { version, description, keywords, + requires, paths: PluginManifestPaths { skills: skills .into_iter() diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 7f9e70d330..044d687e2a 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -6,6 +6,7 @@ use crate::manifest::PluginManifestHooks; use crate::manifest::PluginManifestInterface; use crate::manifest::PluginManifestMcpServers; use crate::manifest::PluginManifestPaths; +use crate::manifest::PluginManifestRequirements; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -42,6 +43,9 @@ fn environment_descriptor_binds_every_manifest_resource() { version: None, description: None, keywords: Vec::new(), + requires: PluginManifestRequirements { + host_capabilities: vec!["codex.inline_visualization".to_string()], + }, paths: PluginManifestPaths { skills: vec![path_uri(&skills)], mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&mcp_servers))), @@ -76,6 +80,9 @@ fn environment_descriptor_binds_every_manifest_resource() { version: None, description: None, keywords: Vec::new(), + requires: PluginManifestRequirements { + host_capabilities: vec!["codex.inline_visualization".to_string()], + }, paths: PluginManifestPaths { skills: vec![resource("executor-1", &skills)], mcp_servers: Some(PluginManifestMcpServers::Path(resource( @@ -108,6 +115,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { version: None, description: None, keywords: Vec::new(), + requires: PluginManifestRequirements::default(), paths: PluginManifestPaths { skills: Vec::new(), mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&outside))), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dc8262f7db..c6e7963e4f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -400,6 +400,7 @@ async fn connect_remote_app_server( client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) @@ -564,6 +565,7 @@ where client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 619163bc66..92b4909ab8 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1069,6 +1069,7 @@ mod tests { client_version: "test".to_string(), experimental_api: true, mcp_server_openai_form_elicitation: false, + host_capabilities: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, })