feat(plugins): gate contributions on host capabilities

This commit is contained in:
Casey Chow
2026-07-09 20:26:25 +00:00
parent 22781d4001
commit 4338591d88
68 changed files with 2081 additions and 313 deletions
+1
View File
@@ -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",
@@ -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(),
+34 -6
View File
@@ -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<String>,
/// Notification methods this client opts out of receiving.
pub opt_out_notification_methods: Vec<String>,
/// 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,
}
+3
View File
@@ -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<String>,
pub opt_out_notification_methods: Vec<String>,
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 {
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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<string> | null,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `thread/started`).
@@ -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(),
@@ -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<Vec<String>>,
/// Exact notification method names that should be suppressed for this
/// connection (for example `thread/started`).
#[ts(optional = nullable)]
@@ -1671,6 +1671,7 @@ impl CodexClient {
.collect(),
),
mcp_server_openai_form_elicitation: false,
host_capabilities: None,
}),
},
};
+13
View File
@@ -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
+1
View File
@@ -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())
+7 -4
View File
@@ -501,13 +501,16 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
created = thread_created_rx.recv(), if listen_for_threads => {
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::<ConnectionId>::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(_)) => {
+6 -3
View File
@@ -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;
}
+69 -38
View File
@@ -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<ConnectionId>,
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<String>,
client_version: Option<String>,
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, .. } => {
@@ -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<String>,
pub(crate) version: Option<String>,
}
mod account_processor;
mod apps_processor;
mod catalog_processor;
@@ -601,6 +608,21 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> 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;
@@ -36,8 +36,9 @@ impl AppsRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, 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<Option<AppsListResponse>, 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<PluginsManager>,
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<OutgoingMessageSender>,
connection_id: ConnectionId,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
@@ -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<OutgoingMessageSender>,
connection_id: ConnectionId,
data: Vec<AppInfo>,
) {
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(_))
));
}
}
@@ -120,8 +120,9 @@ impl CatalogRequestProcessor {
pub(crate) async fn skills_list(
&self,
params: SkillsListParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<SkillsListResponse, JSONRPCErrorError> {
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<HooksListResponse, JSONRPCErrorError> {
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
@@ -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()
{
@@ -27,9 +27,11 @@ impl McpRequestProcessor {
pub(crate) async fn mcp_server_oauth_login(
&self,
request_id: &ConnectionRequestId,
params: McpServerOauthLoginParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<McpServerRefreshResponse, JSONRPCErrorError> {
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<McpServerOauthLoginResponse, JSONRPCErrorError> {
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, &params.thread_id);
let request_id = request_id.clone();
tokio::spawn(async move {
@@ -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<DomainAuthMode>,
) -> (HashMap<String, McpServerConfig>, Vec<AppConnectorId>) {
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<Option<ClientResponsePayload>, 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<PluginInstallResponse, JSONRPCErrorError> {
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<PluginInstallResponse, JSONRPCErrorError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<String, McpServerConfig>,
) {
@@ -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,
));
}
}
@@ -451,17 +451,17 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
request_context: RequestContext,
) -> Result<Option<ClientResponsePayload>, 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<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, 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<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<String>,
app_server_client_version: Option<String>,
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<String>,
app_server_client_version: Option<String>,
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<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
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<ThreadCompactStartResponse, JSONRPCErrorError> {
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<ConnectionId>,
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<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.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,
&params,
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<String>,
app_server_client_version: Option<String>,
app_server_client_info: &AppServerClientInfo,
host_capabilities: &HostCapabilities,
) -> Result<RunningThreadResumeResult, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.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<String>,
app_server_client_version: Option<String>,
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()
{
@@ -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!(
@@ -156,17 +156,17 @@ impl TurnRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: TurnStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
validate_user_input_image_urls(&params.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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, JSONRPCErrorError> {
validate_user_input_image_urls(&params.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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<Option<ClientResponsePayload>, 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<String>,
app_server_client_version: Option<String>,
app_server_client_info: AppServerClientInfo,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.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(&params.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<ThreadInjectItemsResponse, JSONRPCErrorError> {
let (_, thread) = self.load_thread(&params.thread_id).await?;
let (thread_id, thread) = self.load_thread(&params.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<String>,
app_server_client_version: Option<String>,
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<TurnSteerResponse, JSONRPCErrorError> {
let (_, thread) = self
.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(request_id, error, /*error_type*/ None);
})?;
let (thread_id, thread) =
self.load_thread(&params.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<Option<(ThreadId, Arc<CodexThread>)>, 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<Option<ThreadRealtimeStartResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.prepare_realtime_conversation_thread(request_id, &params.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<Option<ThreadRealtimeAppendAudioResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.prepare_realtime_conversation_thread(request_id, &params.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<Option<ThreadRealtimeAppendTextResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.prepare_realtime_conversation_thread(request_id, &params.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<Option<ThreadRealtimeAppendSpeechResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.prepare_realtime_conversation_thread(request_id, &params.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<Option<ThreadRealtimeStopResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.prepare_realtime_conversation_thread(request_id, &params.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 => {
@@ -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,
}),
),
)
@@ -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<MaybeTlsStream<tokio::net::TcpStream>>;
type HmacSha256 = Hmac<Sha256>;
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<Vec<&str>>,
) -> 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,
@@ -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?;
@@ -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,
}),
),
)
@@ -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,
}),
),
)
+3 -1
View File
@@ -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:#}")
}
@@ -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<_>>(),
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");
+8 -1
View File
@@ -178,8 +178,9 @@ pub async fn load_plugin_hooks_from_layer_stack(
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
remote_global_catalog_active: bool,
host_capabilities: &HashSet<String>,
) -> 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,
+18 -8
View File
@@ -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<String>,
}
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<HashMap<RecommendedPluginsCacheKey, Arc<OnceCell<RecommendedPluginsMode>>>>,
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
// 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<LoadedPluginsCache>,
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<LoadedPlugin>) -> PluginLoadOutcome {
fn resolve_loaded_plugins_for_runtime(
&self,
mut plugins: Vec<LoadedPlugin>,
host_capabilities: &HashSet<String>,
) -> 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<PluginCapabilitySummary, MarketplaceError> {
host_capabilities: &HashSet<String>,
) -> Result<Option<PluginCapabilitySummary>, 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(
+248
View File
@@ -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::<Vec<_>>()
);
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<_>>(),
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::<String>::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::<String>::new());
}
+88 -6
View File
@@ -16,6 +16,7 @@ pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterfa
pub type PluginManifestMcpServers =
codex_plugin::manifest::PluginManifestMcpServers<AbsolutePathBuf>;
pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths<AbsolutePathBuf>;
pub type PluginManifestRequirements = codex_plugin::manifest::PluginManifestRequirements;
pub(crate) type UriPluginManifest = codex_plugin::manifest::PluginManifest<PathUri>;
@@ -30,6 +31,8 @@ struct RawPluginManifest {
description: Option<String>,
#[serde(default)]
keywords: Vec<String>,
#[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<RawPluginManifestInterface>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestRequirements {
#[serde(default)]
host_capabilities: Vec<String>,
}
#[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(
@@ -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<Arc<ToolSuggestMetadataFragment>, 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<ToolSuggestMetadataCacheState>,
load_semaphore: Semaphore,
@@ -53,6 +55,7 @@ pub(crate) struct ToolSuggestMetadataFragment {
config_name: String,
display_name: String,
description: Option<String>,
required_host_capabilities: Vec<String>,
mcp_server_names: Vec<String>,
app_declarations: Vec<AppDeclaration>,
skill_inventory: Option<PluginSkillInventory>,
@@ -63,7 +66,16 @@ impl ToolSuggestMetadataFragment {
&self,
skill_config_rules: &SkillConfigRules,
auth_mode: Option<AuthMode>,
) -> PluginCapabilitySummary {
host_capabilities: &HashSet<String>,
) -> Option<PluginCapabilitySummary> {
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::<Vec<_>>();
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),
+23 -8
View File
@@ -69,7 +69,7 @@ pub struct SkillsService {
codex_home: AbsolutePathBuf,
restriction_product: Option<Product>,
extra_roots: RwLock<Vec<AbsolutePathBuf>>,
cache_by_cwd: RwLock<HashMap<AbsolutePathBuf, HostSkillsSnapshot>>,
cache_by_cwd: RwLock<HashMap<CwdSkillsCacheKey, HostSkillsSnapshot>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, HostSkillsSnapshot>>,
}
@@ -173,9 +173,17 @@ impl SkillsService {
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> 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<HostSkillsSnapshot> {
fn cached_snapshot_for_cwd(&self, cache_key: &CwdSkillsCacheKey) -> Option<HostSkillsSnapshot> {
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<PluginSkillRoot>,
bundled_skills_enabled: bool,
skill_config_rules: SkillConfigRules,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConfigSkillsCacheKey {
roots: Vec<(AbsolutePathBuf, u8, Option<String>, Option<String>)>,
+62
View File
@@ -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");
+1
View File
@@ -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(),
+5
View File
@@ -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"))
+2
View File
@@ -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<ThreadId>,
pub thread_source: Option<ThreadSource>,
pub originator: String,
pub host_capabilities: HostCapabilities,
}
/// Explains why `CodexThread::try_start_turn_if_idle` rejected an automatic
+11 -1
View File
@@ -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"))
+19 -2
View File
@@ -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<String>;
/// 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<String>,
/// 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<String>,
@@ -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<Vec<AbsolutePathBuf>>,
/// Opaque host features supplied by the embedding client for this thread.
pub host_capabilities: HostCapabilities,
}
fn dedupe_absolute_paths(paths: &mut Vec<AbsolutePathBuf>) {
@@ -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,
+2
View File
@@ -50,6 +50,7 @@ struct AccessibleConnectorsCacheKey {
account_id: Option<String>,
chatgpt_user_id: Option<String>,
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(),
}
}
+10
View File
@@ -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")
});
+1
View File
@@ -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(),
}
}
+2
View File
@@ -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,
};
+14
View File
@@ -24,6 +24,7 @@ mod provider;
struct SelectedPluginMetadata {
plugin_id: String,
plugin_display_name: String,
required_host_capabilities: Vec<String>,
servers: Vec<(String, codex_config::McpServerConfig)>,
connector_ids: Vec<String>,
}
@@ -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<Config> 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::<HashMap<_, _>>();
context
.config()
@@ -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,
@@ -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 = &registry.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,
+1
View File
@@ -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 }
+50
View File
@@ -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<SkillDependencies>,
pub enabled: bool,
pub prompt_visible: bool,
pub(crate) required_host_capabilities: Vec<String>,
}
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<String>,
) -> 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<String>) -> 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<SkillCatalogEntry>,
pub warnings: Vec<String>,
/// Warnings whose visibility depends on the host capabilities available at projection time.
#[doc(hidden)]
pub host_capability_gated_warnings: Vec<HostCapabilityGatedWarnings>,
}
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<String>,
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<String>,
pub warnings: Vec<String>,
}
/// Contents returned after resolving a skill resource through its owner.
+4
View File
@@ -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<String>,
}
+3
View File
@@ -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::<McpResourceClient>(),
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::<McpResourceClient>(),
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::<McpResourceClient>(),
host_capabilities: config.host_capabilities.clone(),
};
let mut catalog = self.list_skills(query, &thread_state).await;
if let Some(executor_skills) = turn_store.get::<ExecutorSkillsStepState>() {
+2
View File
@@ -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<Arc<McpResourceClient>>,
pub host_capabilities: BTreeSet<String>,
}
#[derive(Clone, Debug)]
+37 -6
View File
@@ -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<EnvironmentManager>,
plugin_provider: ExecutorPluginProvider,
restriction_product: Option<Product>,
}
@@ -35,6 +37,7 @@ impl ExecutorSkillProvider {
restriction_product: Option<Product>,
) -> 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
+1
View File
@@ -95,6 +95,7 @@ fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog {
)
})
.collect(),
..Default::default()
};
for (skill, enabled) in outcome.skills_with_enabled() {
+17 -4
View File
@@ -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
}
+1
View File
@@ -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
@@ -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<std::path::PathBuf> {
let id = NEXT_TEST_ROOT_ID.fetch_add(1, Ordering::Relaxed);
let test_root = std::env::temp_dir().join(format!(
@@ -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<String>,
}
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(),
}
}
+14 -1
View File
@@ -21,6 +21,8 @@ pub struct LoadedPlugin<M> {
pub manifest_description: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub required_host_capabilities: Vec<String>,
pub missing_host_capabilities: Vec<String>,
pub skill_roots: Vec<AbsolutePathBuf>,
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
pub has_enabled_skills: bool,
@@ -33,12 +35,21 @@ pub struct LoadedPlugin<M> {
impl<M> LoadedPlugin<M> {
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<String>) {
self.missing_host_capabilities = self
.required_host_capabilities
.iter()
.filter(|capability| !host_capabilities.contains(*capability))
.cloned()
.collect::<Vec<_>>();
}
}
fn plugin_capability_summary_from_loaded<M>(
@@ -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,
+10
View File
@@ -10,10 +10,18 @@ pub struct PluginManifest<Resource> {
pub version: Option<String>,
pub description: Option<String>,
pub keywords: Vec<String>,
pub requires: PluginManifestRequirements,
pub paths: PluginManifestPaths<Resource>,
pub interface: Option<PluginManifestInterface<Resource>>,
}
/// 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<String>,
}
/// Component resources declared by a plugin manifest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths<Resource> {
@@ -100,6 +108,7 @@ impl<Resource> PluginManifest<Resource> {
version,
description,
keywords,
requires,
paths,
interface,
} = self;
@@ -176,6 +185,7 @@ impl<Resource> PluginManifest<Resource> {
version,
description,
keywords,
requires,
paths: PluginManifestPaths {
skills: skills
.into_iter()
+8
View File
@@ -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))),
+2
View File
@@ -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,
})
+1
View File
@@ -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,
})