refactor(plugins): split host capability projection base

This commit is contained in:
Casey Chow
2026-07-10 01:05:17 +00:00
parent 09064d0f8a
commit ca39621bee
74 changed files with 546 additions and 1892 deletions
-1
View File
@@ -3990,7 +3990,6 @@ dependencies = [
name = "codex-skills-extension"
version = "0.0.0"
dependencies = [
"codex-core-plugins",
"codex-core-skills",
"codex-exec-server",
"codex-extension-api",
@@ -801,7 +801,6 @@ 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(),
@@ -1683,7 +1682,6 @@ 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(),
@@ -1988,7 +1986,6 @@ 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(),
@@ -2119,7 +2116,6 @@ 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(),
+6 -34
View File
@@ -349,8 +349,6 @@ 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).
@@ -376,8 +374,6 @@ 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 {
@@ -1049,7 +1045,6 @@ 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,
})
@@ -1244,7 +1239,6 @@ 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,
}
@@ -1263,20 +1257,6 @@ 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;
@@ -1548,7 +1528,6 @@ 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,
})
@@ -1638,7 +1617,6 @@ 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,
})
@@ -1659,7 +1637,6 @@ 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,
})
@@ -2264,22 +2241,18 @@ 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);
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!(
runtime_args
.initialize
.capabilities
.expect("initialize capabilities")
.mcp_server_openai_form_elicitation
);
assert!(Arc::ptr_eq(
&runtime_args.environment_manager,
@@ -2317,7 +2290,6 @@ 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,7 +87,6 @@ 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,
}
@@ -102,8 +101,6 @@ 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,16 +1279,6 @@
"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,16 +2923,6 @@
"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,16 +7532,6 @@
"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,16 +30,6 @@
"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,10 +18,6 @@ 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,7 +2209,6 @@ 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(),
@@ -2232,7 +2231,6 @@ mod tests {
"experimentalApi": true,
"requestAttestation": true,
"mcpServerOpenaiFormElicitation": true,
"hostCapabilities": ["codex.inline_visualization"],
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -2260,7 +2258,6 @@ mod tests {
"experimentalApi": true,
"requestAttestation": true,
"mcpServerOpenaiFormElicitation": true,
"hostCapabilities": ["codex.inline_visualization"],
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -2283,7 +2280,6 @@ 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,9 +53,6 @@ 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,7 +1671,6 @@ impl CodexClient {
.collect(),
),
mcp_server_openai_form_elicitation: false,
host_capabilities: None,
}),
},
};
+1 -15
View File
@@ -93,20 +93,6 @@ 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
performing model-visible runtime operations on that thread. Capability-independent
settings updates remain available; the next `turn/start` enforces compatibility.
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
@@ -1836,7 +1822,7 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat
} }
```
When `threadId` is provided, app feature gating (`Feature::Apps`) is evaluated using that thread's config snapshot, and the requesting connection must have the same host capability set as the loaded thread. When omitted, the latest global config is used.
When `threadId` is provided, app feature gating (`Feature::Apps`) is evaluated using that thread's config snapshot. When omitted, the latest global config is used.
`app/list` returns after both accessible apps and directory apps are loaded. Set `forceRefetch: true` to bypass app caches and fetch fresh data from sources. Cache entries are only replaced when those refetches succeed.
-1
View File
@@ -91,7 +91,6 @@ 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())
+4 -7
View File
@@ -501,16 +501,13 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
created = thread_created_rx.recv(), if listen_for_threads => {
match created {
Ok(thread_id) => {
let connections = if session.initialized() {
vec![(
IN_PROCESS_CONNECTION_ID,
session.host_capabilities(),
)]
let connection_ids = if session.initialized() {
vec![IN_PROCESS_CONNECTION_ID]
} else {
Vec::new()
Vec::<ConnectionId>::new()
};
processor
.try_attach_thread_listener(thread_id, connections)
.try_attach_thread_listener(thread_id, connection_ids)
.await;
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
+3 -6
View File
@@ -1125,19 +1125,16 @@ 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_connections = Vec::new();
let mut initialized_connection_ids = Vec::new();
for (connection_id, connection_state) in &connections {
if connection_state.session.initialized() {
initialized_connections.push((
*connection_id,
connection_state.session.host_capabilities(),
));
initialized_connection_ids.push(*connection_id);
}
}
processor
.try_attach_thread_listener(
thread_id,
initialized_connections,
initialized_connection_ids,
)
.await;
}
+39 -75
View File
@@ -19,7 +19,6 @@ 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;
@@ -66,7 +65,6 @@ 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;
@@ -143,7 +141,6 @@ 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 {
@@ -200,13 +197,6 @@ 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(|_| ())
}
@@ -678,10 +668,10 @@ impl MessageProcessor {
pub(crate) async fn try_attach_thread_listener(
&self,
thread_id: ThreadId,
connections: Vec<(ConnectionId, HostCapabilities)>,
connection_ids: Vec<ConnectionId>,
) {
self.thread_processor
.try_attach_thread_listener(thread_id, connections)
.try_attach_thread_listener(thread_id, connection_ids)
.await;
}
@@ -818,12 +808,9 @@ impl MessageProcessor {
);
let serialization_scope = codex_request.serialization_scope();
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 app_server_client_name = session.app_server_client_name().map(str::to_string);
let client_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);
@@ -837,9 +824,9 @@ impl MessageProcessor {
connection_request_id,
codex_request,
request_context,
app_server_client_info,
app_server_client_name,
client_version,
supports_openai_form_elicitation,
host_capabilities,
)
.await;
if let Err(error) = result {
@@ -867,9 +854,9 @@ impl MessageProcessor {
connection_request_id: ConnectionRequestId,
codex_request: ClientRequest,
request_context: RequestContext,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
let request_id = ConnectionRequestId {
@@ -921,7 +908,7 @@ impl MessageProcessor {
.remote_control_processor
.enable(
params.is_some_and(|params| params.ephemeral),
app_server_client_info.name.as_deref(),
app_server_client_name.as_deref(),
)
.await
.map(|response| Some(response.into())),
@@ -929,7 +916,7 @@ impl MessageProcessor {
.remote_control_processor
.disable(
params.is_some_and(|params| params.ephemeral),
app_server_client_info.name.as_deref(),
app_server_client_name.as_deref(),
)
.await
.map(|response| Some(response.into())),
@@ -939,7 +926,7 @@ impl MessageProcessor {
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStart { params, .. } => self
.remote_control_processor
.pairing_start(params, app_server_client_info.name.as_deref())
.pairing_start(params, app_server_client_name.as_deref())
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStatus { params, .. } => self
@@ -1023,9 +1010,9 @@ impl MessageProcessor {
.thread_start(
request_id.clone(),
params,
app_server_client_info.clone(),
app_server_client_name.clone(),
client_version.clone(),
supports_openai_form_elicitation,
host_capabilities.clone(),
request_context,
)
.await
@@ -1040,10 +1027,10 @@ impl MessageProcessor {
.thread_resume(
request_id.clone(),
params,
app_server_client_info.clone(),
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
host_capabilities.clone(),
)
.await
}
@@ -1052,10 +1039,10 @@ impl MessageProcessor {
.thread_fork(
request_id.clone(),
params,
app_server_client_info.clone(),
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
host_capabilities.clone(),
)
.await
}
@@ -1116,7 +1103,7 @@ impl MessageProcessor {
}
ClientRequest::ThreadCompactStart { params, .. } => {
self.thread_processor
.thread_compact_start(&request_id, params, host_capabilities.clone())
.thread_compact_start(&request_id, params)
.await
}
ClientRequest::ThreadBackgroundTerminalsClean { params, .. } => {
@@ -1136,12 +1123,7 @@ impl MessageProcessor {
}
ClientRequest::ThreadRollback { params, .. } => {
self.thread_processor
.thread_rollback(
&request_id,
params,
app_server_client_info.name.as_deref(),
host_capabilities.clone(),
)
.thread_rollback(&request_id, params, app_server_client_name.as_deref())
.await
}
ClientRequest::ThreadList { params, .. } => {
@@ -1176,17 +1158,13 @@ impl MessageProcessor {
self.thread_processor.conversation_summary(params).await
}
ClientRequest::SkillsList { params, .. } => {
self.catalog_processor
.skills_list(params, host_capabilities.clone())
.await
self.catalog_processor.skills_list(params).await
}
ClientRequest::SkillsExtraRootsSet { params, .. } => {
self.catalog_processor.skills_extra_roots_set(params).await
}
ClientRequest::HooksList { params, .. } => {
self.catalog_processor
.hooks_list(params, host_capabilities.clone())
.await
self.catalog_processor.hooks_list(params).await
}
ClientRequest::MarketplaceAdd { params, .. } => {
self.marketplace_processor.marketplace_add(params).await
@@ -1227,17 +1205,13 @@ impl MessageProcessor {
self.plugin_processor.plugin_share_delete(params).await
}
ClientRequest::AppsList { params, .. } => {
self.apps_processor
.apps_list(&request_id, params, host_capabilities.clone())
.await
self.apps_processor.apps_list(&request_id, params).await
}
ClientRequest::SkillsConfigWrite { params, .. } => {
self.catalog_processor.skills_config_write(params).await
}
ClientRequest::PluginInstall { params, .. } => {
self.plugin_processor
.plugin_install(&request_id, params, host_capabilities.clone())
.await
self.plugin_processor.plugin_install(params).await
}
ClientRequest::PluginUninstall { params, .. } => {
self.plugin_processor.plugin_uninstall(params).await
@@ -1266,84 +1240,74 @@ impl MessageProcessor {
.turn_start(
request_id.clone(),
params,
app_server_client_info,
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
host_capabilities,
)
.await
}
ClientRequest::ThreadInjectItems { params, .. } => {
self.turn_processor
.thread_inject_items(params, host_capabilities.clone())
.await
self.turn_processor.thread_inject_items(params).await
}
ClientRequest::TurnSteer { params, .. } => {
self.turn_processor
.turn_steer(&request_id, params, host_capabilities.clone())
.await
self.turn_processor.turn_steer(&request_id, params).await
}
ClientRequest::TurnInterrupt { params, .. } => {
self.turn_processor
.turn_interrupt(&request_id, params, host_capabilities.clone())
.turn_interrupt(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeStart { params, .. } => {
self.turn_processor
.thread_realtime_start(&request_id, params, host_capabilities.clone())
.thread_realtime_start(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeAppendAudio { params, .. } => {
self.turn_processor
.thread_realtime_append_audio(&request_id, params, host_capabilities.clone())
.thread_realtime_append_audio(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeAppendText { params, .. } => {
self.turn_processor
.thread_realtime_append_text(&request_id, params, host_capabilities.clone())
.thread_realtime_append_text(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeAppendSpeech { params, .. } => {
self.turn_processor
.thread_realtime_append_speech(&request_id, params, host_capabilities.clone())
.thread_realtime_append_speech(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeStop { params, .. } => {
self.turn_processor
.thread_realtime_stop(&request_id, params, host_capabilities.clone())
.thread_realtime_stop(&request_id, params)
.await
}
ClientRequest::ThreadRealtimeListVoices { params: _, .. } => {
self.turn_processor.thread_realtime_list_voices().await
}
ClientRequest::ReviewStart { params, .. } => {
self.turn_processor
.review_start(&request_id, params, host_capabilities.clone())
.await
self.turn_processor.review_start(&request_id, params).await
}
ClientRequest::McpServerOauthLogin { params, .. } => {
self.mcp_processor
.mcp_server_oauth_login(&request_id, params, host_capabilities.clone())
.await
self.mcp_processor.mcp_server_oauth_login(params).await
}
ClientRequest::McpServerRefresh { params, .. } => {
self.mcp_processor
.mcp_server_refresh(params, host_capabilities.clone())
.await
self.mcp_processor.mcp_server_refresh(params).await
}
ClientRequest::McpServerStatusList { params, .. } => {
self.mcp_processor
.mcp_server_status_list(&request_id, params, host_capabilities.clone())
.mcp_server_status_list(&request_id, params)
.await
}
ClientRequest::McpResourceRead { params, .. } => {
self.mcp_processor
.mcp_resource_read(&request_id, params, host_capabilities.clone())
.mcp_resource_read(&request_id, params)
.await
}
ClientRequest::McpServerToolCall { params, .. } => {
self.mcp_processor
.mcp_server_tool_call(&request_id, params, host_capabilities)
.mcp_server_tool_call(&request_id, params)
.await
}
ClientRequest::WindowsSandboxSetupStart { params, .. } => {
@@ -326,7 +326,6 @@ 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;
@@ -496,12 +495,6 @@ 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;
@@ -608,21 +601,6 @@ 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,9 +36,8 @@ impl AppsRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.apps_list_inner(request_id, params, host_capabilities)
self.apps_list_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -47,19 +46,11 @@ 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;
let thread = if let Some(thread_id) = params.thread_id.as_deref() {
let (thread_id, loaded_thread) = self.load_thread(thread_id).await?;
ensure_thread_host_capabilities(
thread_id,
loaded_thread.as_ref(),
&host_capabilities,
"list apps",
)
.await?;
let (_, loaded_thread) = self.load_thread(thread_id).await?;
Some(loaded_thread)
} else {
None
@@ -69,7 +60,6 @@ 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
@@ -141,7 +131,6 @@ 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();
@@ -150,7 +139,6 @@ impl AppsRequestProcessor {
let retry_plugins_manager = Arc::clone(&plugins_manager);
let result = Self::apps_list_response(
&outgoing,
connection_id,
params,
config,
environment_manager,
@@ -173,7 +161,6 @@ 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,
@@ -189,7 +176,6 @@ impl AppsRequestProcessor {
async fn apps_list_response(
outgoing: &Arc<OutgoingMessageSender>,
connection_id: ConnectionId,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
@@ -269,7 +255,7 @@ impl AppsRequestProcessor {
accessible_loaded,
all_loaded,
) {
send_app_list_updated_notification(outgoing, connection_id, merged.clone()).await;
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged);
}
}
@@ -329,7 +315,7 @@ impl AppsRequestProcessor {
all_loaded,
) && last_notified_apps.as_ref() != Some(&merged)
{
send_app_list_updated_notification(outgoing, connection_id, merged.clone()).await;
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged.clone());
}
@@ -458,47 +444,12 @@ 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_to_connections(
&[connection_id],
ServerNotification::AppListUpdated(AppListUpdatedNotification { data }),
)
.send_server_notification(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,9 +120,8 @@ impl CatalogRequestProcessor {
pub(crate) async fn skills_list(
&self,
params: SkillsListParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_list_response(params, host_capabilities)
self.skills_list_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -130,9 +129,8 @@ impl CatalogRequestProcessor {
pub(crate) async fn hooks_list(
&self,
params: HooksListParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.hooks_list_response(params, host_capabilities)
self.hooks_list_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -490,7 +488,6 @@ 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() {
@@ -499,8 +496,7 @@ impl CatalogRequestProcessor {
cwds
};
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
config.host_capabilities = host_capabilities;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
@@ -599,7 +595,6 @@ 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() {
@@ -616,10 +611,7 @@ impl CatalogRequestProcessor {
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides {
host_capabilities: host_capabilities.clone(),
..Default::default()
},
ConfigOverrides::default(),
Some(cwd.clone()),
)
.await
@@ -71,15 +71,6 @@ 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();
@@ -107,7 +98,6 @@ impl InitializeRequestProcessor {
client_version: version,
request_attestation,
supports_openai_form_elicitation,
host_capabilities,
})
.is_err()
{
@@ -27,11 +27,9 @@ 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(request_id, params, host_capabilities)
self.mcp_server_oauth_login_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -39,9 +37,8 @@ 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, host_capabilities)
self.mcp_server_refresh_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -50,9 +47,8 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.list_mcp_server_status(request_id, params, host_capabilities)
self.list_mcp_server_status(request_id, params)
.await
.map(|()| None)
}
@@ -61,9 +57,8 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.read_mcp_resource(request_id, params, host_capabilities)
self.read_mcp_resource(request_id, params)
.await
.map(|()| None)
}
@@ -72,9 +67,8 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.call_mcp_server_tool(request_id, params, host_capabilities)
self.call_mcp_server_tool(request_id, params)
.await
.map(|()| None)
}
@@ -82,7 +76,6 @@ 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
@@ -118,9 +111,7 @@ impl McpRequestProcessor {
async fn mcp_server_oauth_login_response(
&self,
request_id: &ConnectionRequestId,
params: McpServerOauthLoginParams,
host_capabilities: HostCapabilities,
) -> Result<McpServerOauthLoginResponse, JSONRPCErrorError> {
let McpServerOauthLoginParams {
name,
@@ -132,20 +123,12 @@ impl McpRequestProcessor {
let auth = self.auth_manager.auth().await;
let (mcp_config, runtime_context) = match thread_id.as_deref() {
Some(thread_id) => {
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 (_, thread) = self.load_thread(thread_id).await?;
let runtime = thread.current_mcp_runtime().await;
(runtime.config().clone(), runtime.runtime_context().clone())
}
None => {
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
config.host_capabilities = host_capabilities;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_config = self
.thread_manager
.mcp_manager()
@@ -217,7 +200,6 @@ 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 {
@@ -234,12 +216,7 @@ impl McpRequestProcessor {
error,
},
);
outgoing
.send_server_notification_to_connections(
&[notification_connection_id],
notification,
)
.await;
outgoing.send_server_notification(notification).await;
});
Ok(McpServerOauthLoginResponse { authorization_url })
@@ -249,35 +226,22 @@ 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_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) = self.load_thread(thread_id).await?;
let thread_config = thread.config().await;
let mut config = self
let 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 => {
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
config.host_capabilities = host_capabilities;
(config, None)
}
None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None),
};
let mcp_manager = self.thread_manager.mcp_manager();
let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache();
@@ -423,7 +387,6 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let McpResourceReadParams {
@@ -433,14 +396,7 @@ impl McpRequestProcessor {
} = params;
if let Some(thread_id) = thread_id {
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 (_, thread) = self.load_thread(&thread_id).await?;
let request_id = request_id.clone();
tokio::spawn(async move {
@@ -450,8 +406,7 @@ impl McpRequestProcessor {
return Ok(());
}
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
config.host_capabilities = host_capabilities;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
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();
@@ -501,19 +456,11 @@ 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_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 (_, thread) = self.load_thread(&thread_id).await?;
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
let request_id = request_id.clone();
tokio::spawn(async move {
@@ -23,7 +23,6 @@ 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;
@@ -89,39 +88,6 @@ 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 },
@@ -486,11 +452,9 @@ 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(request_id, params, host_capabilities)
self.plugin_install_response(params)
.await
.map(|response| Some(response.into()))
}
@@ -1458,9 +1422,7 @@ impl PluginRequestProcessor {
async fn plugin_install_response(
&self,
request_id: &ConnectionRequestId,
params: PluginInstallParams,
host_capabilities: HostCapabilities,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let PluginInstallParams {
marketplace_path,
@@ -1471,12 +1433,7 @@ impl PluginRequestProcessor {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
return self
.remote_plugin_install_response(
request_id,
remote_marketplace_name,
plugin_name,
host_capabilities,
)
.remote_plugin_install_response(remote_marketplace_name, plugin_name)
.await;
}
(Some(_), Some(_)) | (None, None) => {
@@ -1486,8 +1443,7 @@ impl PluginRequestProcessor {
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let mut config = self.load_latest_config(config_cwd.clone()).await?;
config.host_capabilities = host_capabilities.clone();
let config = self.load_latest_config(config_cwd.clone()).await?;
let auth = self.auth_manager.auth().await;
if !self
@@ -1521,7 +1477,7 @@ impl PluginRequestProcessor {
return Err(Self::plugin_install_error(err));
}
};
let mut config = match self.load_latest_config(config_cwd).await {
let config = match self.load_latest_config(config_cwd).await {
Ok(config) => config,
Err(err) => {
warn!(
@@ -1530,25 +1486,22 @@ impl PluginRequestProcessor {
config
}
};
config.host_capabilities = host_capabilities;
self.on_effective_plugins_changed();
let (plugin_mcp_servers, plugin_apps) = load_plugin_auth_capabilities_for_host(
&config,
let plugin_mcp_servers = load_plugin_mcp_servers(
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(
request_id.connection_id,
&config,
plugin_mcp_servers,
)
.await;
self.start_plugin_mcp_oauth_logins(&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,
@@ -1566,13 +1519,10 @@ 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 mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
config.host_capabilities = host_capabilities;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
@@ -1716,72 +1666,66 @@ impl PluginRequestProcessor {
self.analytics_events_client
.track_plugin_installed(plugin_metadata);
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,
let plugin_mcp_servers = load_plugin_mcp_servers(
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(
request_id.connection_id,
&config,
plugin_mcp_servers,
)
.await;
self.start_plugin_mcp_oauth_logins(&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)
|| !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()
}
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()
} else {
self.plugin_apps_needing_auth_for_install(
&config,
is_chatgpt_auth,
&result.plugin_id.as_key(),
&plugin_apps,
)
.await
};
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
};
Ok(PluginInstallResponse {
auth_policy: remote_detail.summary.auth_policy,
@@ -1891,7 +1835,6 @@ impl PluginRequestProcessor {
async fn start_plugin_mcp_oauth_logins(
&self,
connection_id: ConnectionId,
config: &Config,
plugin_mcp_servers: HashMap<String, McpServerConfig>,
) {
@@ -1970,9 +1913,7 @@ impl PluginRequestProcessor {
error,
},
);
outgoing
.send_server_notification_to_connections(&[connection_id], notification)
.await;
outgoing.send_server_notification(notification).await;
});
}
}
@@ -2457,37 +2398,3 @@ 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_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
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_info,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
host_capabilities,
request_context,
)
.await
@@ -482,16 +482,16 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(
request_id,
params,
app_server_client_info,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
host_capabilities,
)
.await
.map(|()| None)
@@ -501,16 +501,16 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(
request_id,
params,
app_server_client_info,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
host_capabilities,
)
.await
.map(|()| None)
@@ -629,9 +629,8 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadCompactStartParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_compact_start_inner(request_id, params, host_capabilities)
self.thread_compact_start_inner(request_id, params)
.await
.map(|response| Some(response.into()))
}
@@ -669,13 +668,12 @@ impl ThreadRequestProcessor {
request_id: &ConnectionRequestId,
params: ThreadRollbackParams,
app_server_client_name: Option<&str>,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
if app_server_client_name != Some(CODEX_TUI_CLIENT_NAME) {
self.send_thread_rollback_deprecation_notice(request_id.connection_id)
.await;
}
self.thread_rollback_inner(request_id, params, host_capabilities)
self.thread_rollback_inner(request_id, params)
.await
.map(|()| None)
}
@@ -804,13 +802,19 @@ impl ThreadRequestProcessor {
async fn set_app_server_client_info(
thread: &CodexThread,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
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());
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(name, version, mcp_elicitations_auto_deny)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
@@ -927,9 +931,9 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadStartParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
request_context: RequestContext,
) -> Result<(), JSONRPCErrorError> {
let ThreadStartParams {
@@ -981,7 +985,6 @@ 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),
@@ -1004,7 +1007,8 @@ impl ThreadRequestProcessor {
listener_task_context,
config_manager,
request_id,
app_server_client_info,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
config,
typesafe_overrides,
@@ -1080,7 +1084,8 @@ impl ThreadRequestProcessor {
listener_task_context: ListenerTaskContext,
config_manager: ConfigManager,
request_id: ConnectionRequestId,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
@@ -1249,7 +1254,12 @@ impl ThreadRequestProcessor {
Some("ready"),
);
Self::set_app_server_client_info(thread.as_ref(), app_server_client_info).await?;
Self::set_app_server_client_info(
thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
let instruction_sources = thread.legacy_instruction_sources().await;
let config_snapshot = thread
@@ -1751,17 +1761,14 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRollbackParams,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
self.thread_rollback_start(request_id, params, host_capabilities)
.await
self.thread_rollback_start(request_id, params).await
}
async fn thread_rollback_start(
&self,
request_id: &ConnectionRequestId,
params: ThreadRollbackParams,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
let ThreadRollbackParams {
thread_id,
@@ -1773,13 +1780,6 @@ impl ThreadRequestProcessor {
}
let (thread_id, thread) = self.load_thread(&thread_id).await?;
ensure_thread_host_capabilities(
thread_id,
thread.as_ref(),
&host_capabilities,
"roll back",
)
.await?;
let request = request_id.clone();
@@ -1821,18 +1821,10 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadCompactStartParams,
host_capabilities: HostCapabilities,
) -> Result<ThreadCompactStartResponse, JSONRPCErrorError> {
let ThreadCompactStartParams { thread_id } = params;
let (thread_id, thread) = self.load_thread(&thread_id).await?;
ensure_thread_host_capabilities(
thread_id,
thread.as_ref(),
&host_capabilities,
"start compaction",
)
.await?;
let (_, thread) = self.load_thread(&thread_id).await?;
self.submit_core_op(request_id, thread.as_ref(), Op::Compact)
.await
.map_err(|err| internal_error(format!("failed to start compaction: {err}")))?;
@@ -2635,35 +2627,30 @@ impl ThreadRequestProcessor {
pub(crate) async fn try_attach_thread_listener(
&self,
thread_id: ThreadId,
connections: Vec<(ConnectionId, HostCapabilities)>,
connection_ids: Vec<ConnectionId>,
) {
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, host_capabilities) in connections {
if host_capabilities != thread_host_capabilities {
continue;
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;
}
}
for connection_id in connection_ids {
log_listener_attach_result(
self.ensure_conversation_listener(thread_id, connection_id, raw_events_enabled)
.await,
@@ -2678,9 +2665,9 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadResumeParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2710,7 +2697,7 @@ impl ThreadRequestProcessor {
return Ok(());
}
let redact_resume_payloads =
should_redact_thread_resume_payloads(app_server_client_info.name.as_deref());
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await {
Ok(permit) => permit,
@@ -2723,8 +2710,8 @@ impl ThreadRequestProcessor {
.resume_running_thread(
&request_id,
&params,
&app_server_client_info,
&host_capabilities,
app_server_client_name.clone(),
app_server_client_version.clone(),
)
.await
{
@@ -2795,7 +2782,6 @@ impl ThreadRequestProcessor {
developer_instructions,
personality,
);
typesafe_overrides.host_capabilities = host_capabilities;
self.load_and_apply_persisted_resume_metadata(
&thread_history,
&mut request_overrides,
@@ -2836,9 +2822,12 @@ impl ThreadRequestProcessor {
session_configured,
..
}) => {
if let Err(err) =
Self::set_app_server_client_info(codex_thread.as_ref(), app_server_client_info)
.await
if let Err(err) = Self::set_app_server_client_info(
codex_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await
{
self.outgoing.send_error(request_id, err).await;
return Ok(());
@@ -3019,8 +3008,8 @@ impl ThreadRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: &ThreadResumeParams,
app_server_client_info: &AppServerClientInfo,
host_capabilities: &HostCapabilities,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<RunningThreadResumeResult, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
@@ -3080,12 +3069,6 @@ 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
@@ -3130,7 +3113,7 @@ impl ThreadRequestProcessor {
);
}
let redact_resume_payloads =
should_redact_thread_resume_payloads(app_server_client_info.name.as_deref());
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
let history_items = source_thread
.history
.take()
@@ -3153,7 +3136,8 @@ impl ThreadRequestProcessor {
.await?;
Self::set_app_server_client_info(
existing_thread.as_ref(),
app_server_client_info.clone(),
app_server_client_name,
app_server_client_version,
)
.await?;
@@ -3451,9 +3435,9 @@ impl ThreadRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: ThreadForkParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -3545,7 +3529,6 @@ 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
@@ -3584,7 +3567,12 @@ impl ThreadRequestProcessor {
err => internal_error(format!("error forking thread: {err}")),
})?;
Self::set_app_server_client_info(forked_thread.as_ref(), app_server_client_info).await?;
Self::set_app_server_client_info(
forked_thread.as_ref(),
app_server_client_name,
app_server_client_version,
)
.await?;
if session_configured.rollout_path.is_some()
&& let Some(name) = source_thread_name.clone()
{
@@ -783,7 +783,6 @@ 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_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
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_info,
app_server_client_name,
app_server_client_version,
/*supports_openai_form_elicitation*/ supports_openai_form_elicitation,
host_capabilities,
)
.await
.map(|response| Some(response.into()))
@@ -175,9 +175,8 @@ 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, host_capabilities)
self.thread_inject_items_response_inner(params)
.await
.map(|response| Some(response.into()))
}
@@ -196,10 +195,9 @@ 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, host_capabilities)
self.turn_steer_inner(request_id, params)
.await
.map(|response| Some(response.into()))
}
@@ -208,9 +206,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: TurnInterruptParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.turn_interrupt_inner(request_id, params, host_capabilities)
self.turn_interrupt_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -219,9 +216,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeStartParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_start_inner(request_id, params, host_capabilities)
self.thread_realtime_start_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -230,9 +226,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeAppendAudioParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_append_audio_inner(request_id, params, host_capabilities)
self.thread_realtime_append_audio_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -241,9 +236,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeAppendTextParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_append_text_inner(request_id, params, host_capabilities)
self.thread_realtime_append_text_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -252,9 +246,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeAppendSpeechParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_append_speech_inner(request_id, params, host_capabilities)
self.thread_realtime_append_speech_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -263,9 +256,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeStopParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_stop_inner(request_id, params, host_capabilities)
self.thread_realtime_stop_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
@@ -285,9 +277,8 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
host_capabilities: HostCapabilities,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.review_start_inner(request_id, params, host_capabilities)
self.review_start_inner(request_id, params)
.await
.map(|()| None)
}
@@ -452,9 +443,9 @@ impl TurnRequestProcessor {
&self,
request_id: ConnectionRequestId,
params: TurnStartParams,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
host_capabilities: HostCapabilities,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.thread_id)
@@ -462,13 +453,6 @@ 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) {
@@ -479,11 +463,15 @@ impl TurnRequestProcessor {
);
return Err(error);
}
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);
})?;
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);
})?;
thread
.set_openai_form_elicitation_support(supports_openai_form_elicitation)
.await
@@ -814,16 +802,8 @@ impl TurnRequestProcessor {
async fn thread_inject_items_response_inner(
&self,
params: ThreadInjectItemsParams,
host_capabilities: HostCapabilities,
) -> Result<ThreadInjectItemsResponse, JSONRPCErrorError> {
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 (_, thread) = self.load_thread(&params.thread_id).await?;
let items = params
.items
@@ -849,13 +829,19 @@ impl TurnRequestProcessor {
async fn set_app_server_client_info(
thread: &CodexThread,
app_server_client_info: AppServerClientInfo,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
) -> Result<(), JSONRPCErrorError> {
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());
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
app_server_client_version.as_deref(),
);
thread
.set_app_server_client_info(name, version, mcp_elicitations_auto_deny)
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
mcp_elicitations_auto_deny,
)
.await
.map_err(|err| internal_error(format!("failed to set app server client info: {err}")))
}
@@ -864,21 +850,13 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: TurnSteerParams,
host_capabilities: HostCapabilities,
) -> Result<TurnSteerResponse, JSONRPCErrorError> {
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?;
let (_, thread) = self
.load_thread(&params.thread_id)
.await
.inspect_err(|error| {
self.track_error_response(request_id, error, /*error_type*/ None);
})?;
self.ensure_direct_input_allowed(request_id, thread.as_ref())
.await?;
@@ -981,16 +959,8 @@ 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(
@@ -1020,10 +990,9 @@ 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, &host_capabilities)
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
else {
return Ok(None);
@@ -1065,10 +1034,9 @@ 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, &host_capabilities)
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
else {
return Ok(None);
@@ -1093,10 +1061,9 @@ 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, &host_capabilities)
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
else {
return Ok(None);
@@ -1122,10 +1089,9 @@ 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, &host_capabilities)
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
else {
return Ok(None);
@@ -1148,10 +1114,9 @@ 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, &host_capabilities)
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
.await?
else {
return Ok(None);
@@ -1251,7 +1216,7 @@ impl TurnRequestProcessor {
))
})?;
let mut config = parent_thread.config().await.as_ref().clone();
let mut config = self.config.as_ref().clone();
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
}
@@ -1344,7 +1309,6 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
host_capabilities: HostCapabilities,
) -> Result<(), JSONRPCErrorError> {
let ReviewStartParams {
thread_id,
@@ -1353,13 +1317,6 @@ 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 => {
@@ -1390,19 +1347,11 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: TurnInterruptParams,
host_capabilities: HostCapabilities,
) -> Result<Option<TurnInterruptResponse>, JSONRPCErrorError> {
let TurnInterruptParams { thread_id, turn_id } = params;
let is_startup_interrupt = turn_id.is_empty();
let (thread_uuid, thread) = self.load_thread(&thread_id).await?;
ensure_thread_host_capabilities(
thread_uuid,
thread.as_ref(),
&host_capabilities,
"interrupt a turn",
)
.await?;
// Record turn interrupts so we can reply when TurnAborted arrives. Startup
// interrupts do not have a turn and are acknowledged after submission.
@@ -85,7 +85,6 @@ 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,7 +8,6 @@ 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;
@@ -18,14 +17,10 @@ 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;
@@ -55,9 +50,6 @@ 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
@@ -70,12 +62,6 @@ 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;
@@ -121,310 +107,6 @@ 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
);
send_request(
&mut mismatched_client,
"app/list",
/*id*/ 11,
Some(json!({
"threadId": thread_id,
})),
)
.await?;
let apps_error = read_error_for_id(&mut mismatched_client, /*id*/ 11)
.await
.context("mismatched-client app/list error")?;
assert!(apps_error.error.message.contains("cannot list apps"));
send_request(
&mut mismatched_client,
"thread/rollback",
/*id*/ 12,
Some(json!({
"threadId": thread_id,
"numTurns": 1,
})),
)
.await?;
let rollback_error = read_error_for_id(&mut mismatched_client, /*id*/ 12)
.await
.context("mismatched-client thread/rollback error")?;
assert!(rollback_error.error.message.contains("cannot roll back"));
send_request(
&mut mismatched_client,
"turn/interrupt",
/*id*/ 13,
Some(json!({
"threadId": thread_id,
"turnId": "turn-under-test",
})),
)
.await?;
let interrupt_error = read_error_for_id(&mut mismatched_client, /*id*/ 13)
.await
.context("mismatched-client turn/interrupt error")?;
assert!(
interrupt_error
.error
.message
.contains("cannot interrupt a turn")
);
send_request(
&mut mismatched_client,
"thread/settings/update",
/*id*/ 14,
Some(json!({
"threadId": thread_id,
})),
)
.await?;
read_response_for_id(&mut mismatched_client, /*id*/ 14)
.await
.context("mismatched-client thread/settings/update response")?;
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;
@@ -1012,21 +694,6 @@ 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,
/*host_capabilities*/ 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 {
@@ -1034,11 +701,7 @@ async fn send_initialize_request_with_host_capabilities(
title: Some("WebSocket Test Client".to_string()),
version: "0.1.0".to_string(),
},
capabilities: host_capabilities.map(|host_capabilities| InitializeCapabilities {
experimental_api: true,
host_capabilities: Some(host_capabilities.into_iter().map(str::to_string).collect()),
..Default::default()
}),
capabilities: None,
};
send_request(
stream,
@@ -44,7 +44,6 @@ 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?;
@@ -81,7 +80,6 @@ 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?;
@@ -133,7 +131,6 @@ 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?;
@@ -173,7 +170,6 @@ 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?;
@@ -213,7 +209,6 @@ 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?;
@@ -268,7 +263,6 @@ 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?;
@@ -311,7 +305,6 @@ 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?;
@@ -353,7 +346,6 @@ 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,7 +232,6 @@ 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,7 +155,6 @@ 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,
}),
),
)
@@ -331,7 +331,7 @@ async fn reprojects_cached_configured_and_fallback_plugins_for_host_capabilities
write_required_manifests(HOST_CAPABILITY_SESSION_OWNED_IAB);
plugins
.host_capabilities
.insert(HOST_CAPABILITY_INLINE_VISUALIZATION.to_string());
.insert(HOST_CAPABILITY_INLINE_VISUALIZATION);
let supported = list_discoverable_plugins(
&plugins_manager,
discovery_input(plugins, &[configured_plugin_id], &[], &[]),
@@ -424,7 +424,7 @@ source = "/tmp/{marketplace_name}"
plugins
.host_capabilities
.insert(HOST_CAPABILITY_INLINE_VISUALIZATION.to_string());
.insert(HOST_CAPABILITY_INLINE_VISUALIZATION);
let supported = list_discoverable_plugins(
&plugins_manager,
discovery_input(
+4 -3
View File
@@ -34,6 +34,7 @@ use codex_core_skills::loader::load_skills_from_roots;
use codex_exec_server::LOCAL_FS;
use codex_mcp::parse_plugin_mcp_config;
use codex_plugin::AppDeclaration;
use codex_plugin::HostCapabilities;
use codex_plugin::LoadedPlugin;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginHookSource;
@@ -178,7 +179,7 @@ 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>,
host_capabilities: &HostCapabilities,
) -> PluginHookLoadOutcome {
let mut plugins = load_plugins_from_layer_stack_with_scope(
config_layer_stack,
@@ -768,8 +769,8 @@ async fn load_plugin(
manifest_description: None,
root,
enabled: plugin.enabled,
required_host_capabilities: Vec::new(),
missing_host_capabilities: Vec::new(),
required_host_capabilities: HostCapabilities::default(),
missing_host_capabilities: HostCapabilities::default(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
+8 -4
View File
@@ -72,6 +72,7 @@ use codex_hooks::plugin_hook_declarations;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_plugin::AppConnectorId;
use codex_plugin::HostCapabilities;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
@@ -110,7 +111,7 @@ pub struct PluginsConfigInput {
pub plugins_enabled: bool,
pub remote_plugin_enabled: bool,
pub chatgpt_base_url: String,
pub host_capabilities: HashSet<String>,
pub host_capabilities: HostCapabilities,
}
impl PluginsConfigInput {
@@ -125,7 +126,7 @@ impl PluginsConfigInput {
plugins_enabled,
remote_plugin_enabled,
chatgpt_base_url,
host_capabilities: HashSet::new(),
host_capabilities: HostCapabilities::default(),
}
}
}
@@ -591,7 +592,7 @@ impl PluginsManager {
fn resolve_loaded_plugins_for_runtime(
&self,
mut plugins: Vec<LoadedPlugin>,
host_capabilities: &HashSet<String>,
host_capabilities: &HostCapabilities,
) -> PluginLoadOutcome {
let auth_mode = self.auth_mode();
for plugin in &mut plugins {
@@ -1257,6 +1258,9 @@ impl PluginsManager {
!installed_plugin_ids.contains(plugin.config_id.as_str())
&& !installed_remote_plugin_ids.contains(plugin.remote_plugin_id.as_str())
&& !disabled_plugin_ids.contains(plugin.config_id.as_str())
&& plugin
.requirements
.is_satisfied_by(&input.plugins_config.host_capabilities)
})
.map(|plugin| {
DiscoverableTool::from(DiscoverablePluginInfo {
@@ -1692,7 +1696,7 @@ impl PluginsManager {
marketplace_name: &str,
plugin: &ConfiguredMarketplacePlugin,
skill_config_rules: &SkillConfigRules,
host_capabilities: &HashSet<String>,
host_capabilities: &HostCapabilities,
) -> Result<Option<PluginCapabilitySummary>, MarketplaceError> {
let fragment = self
.tool_suggest_metadata_cache
+79 -22
View File
@@ -45,6 +45,7 @@ use codex_core_skills::SkillsService;
use codex_core_skills::config_rules::SkillConfigRules;
use codex_login::CodexAuth;
use codex_plugin::AppDeclaration;
use codex_plugin::HostCapabilities;
use codex_plugin::PluginId;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::HookEventName;
@@ -808,10 +809,7 @@ fn write_host_capability_projection_plugin(codex_home: &Path) -> std::path::Path
}
fn set_host_capabilities(config: &mut PluginsConfigInput, capabilities: &[&str]) {
config.host_capabilities = capabilities
.iter()
.map(|capability| (*capability).to_string())
.collect();
config.host_capabilities = HostCapabilities::from_names(capabilities);
}
async fn load_plugins_from_config(
@@ -932,8 +930,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(),
required_host_capabilities: HostCapabilities::default(),
missing_host_capabilities: HostCapabilities::default(),
skill_roots: vec![plugin_root.join("skills").abs()],
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
@@ -1015,17 +1013,14 @@ async fn plugin_host_capability_projection_is_all_of_and_reprojects_cached_plugi
assert_eq!(plugin.error, None);
assert_eq!(
plugin.missing_host_capabilities,
missing_host_capabilities
.iter()
.map(|capability| (*capability).to_string())
.collect::<Vec<_>>()
HostCapabilities::from_names(missing_host_capabilities)
);
assert_eq!(
plugin.required_host_capabilities,
vec![
HostCapabilities::from_names([
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!(
@@ -2355,8 +2350,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(),
required_host_capabilities: HostCapabilities::default(),
missing_host_capabilities: HostCapabilities::default(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -2532,8 +2527,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(),
required_host_capabilities: HostCapabilities::default(),
missing_host_capabilities: HostCapabilities::default(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -5171,13 +5166,17 @@ plugins = true
"name": "slack",
"release": {
"display_name": "Slack",
"app_ids": ["connector_slack"]
"app_ids": ["connector_slack"],
"requires": {"hostCapabilities": []}
}
},
{
"id": "plugin_github",
"name": "github",
"release": {"display_name": "GitHub"}
"release": {
"display_name": "GitHub",
"requires": {"hostCapabilities": []}
}
}
]
}))
@@ -5198,12 +5197,14 @@ plugins = true
remote_plugin_id: "plugin_github".to_string(),
display_name: "GitHub".to_string(),
app_connector_ids: Vec::new(),
requirements: Default::default(),
},
RecommendedPlugin {
config_id: "slack@openai-curated-remote".to_string(),
remote_plugin_id: "plugin_slack".to_string(),
display_name: "Slack".to_string(),
app_connector_ids: vec!["connector_slack".to_string()],
requirements: Default::default(),
},
],
};
@@ -5222,7 +5223,7 @@ plugins = true
}
#[tokio::test]
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
async fn recommended_plugin_candidates_filter_installed_disabled_and_unsupported_plugins() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
@@ -5239,17 +5240,36 @@ plugins = true
{
"id": "plugin_linear",
"name": "linear",
"release": {"display_name": "Linear"}
"release": {
"display_name": "Linear",
"requires": {"hostCapabilities": []}
}
},
{
"id": "plugin_github",
"name": "github",
"release": {"display_name": "GitHub"}
"release": {
"display_name": "GitHub",
"requires": {"hostCapabilities": []}
}
},
{
"id": "plugin_slack",
"name": "slack",
"release": {"display_name": "Slack"}
"release": {
"display_name": "Slack",
"requires": {"hostCapabilities": []}
}
},
{
"id": "plugin_visualize",
"name": "visualize",
"release": {
"display_name": "Visualize",
"requires": {
"hostCapabilities": [HOST_CAPABILITY_INLINE_VISUALIZATION]
}
}
}
]
})))
@@ -5291,6 +5311,43 @@ plugins = true
app_connector_ids: Vec::new(),
})])
);
config
.host_capabilities
.insert(HOST_CAPABILITY_INLINE_VISUALIZATION);
let candidates = manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &config,
loaded_plugins: &loaded_plugins,
auth: Some(&auth),
disabled_tools: &disabled_tools,
app_server_client_name: None,
})
.await;
assert_eq!(
candidates,
Some(vec![
DiscoverableTool::from(DiscoverablePluginInfo {
id: "slack@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugin_slack".to_string()),
name: "Slack".to_string(),
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}),
DiscoverableTool::from(DiscoverablePluginInfo {
id: "visualize@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugin_visualize".to_string()),
name: "Visualize".to_string(),
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}),
])
);
}
#[tokio::test]
+9 -17
View File
@@ -1,4 +1,5 @@
use codex_config::HooksFile;
use codex_plugin::HostCapabilities;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
@@ -188,17 +189,7 @@ 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 host_capabilities = HostCapabilities::from_names(requires.host_capabilities);
let interface = interface.and_then(|interface| {
let RawPluginManifestInterface {
display_name,
@@ -535,6 +526,7 @@ mod tests {
use super::load_plugin_manifest;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_plugin::HostCapabilities;
use codex_plugin::PluginProvider;
use codex_plugin::ResolvedPlugin;
use codex_plugin::manifest::PluginManifest as GenericPluginManifest;
@@ -757,10 +749,10 @@ mod tests {
assert_eq!(
manifest.requires,
PluginManifestRequirements {
host_capabilities: vec![
"codex.inline_visualization".to_string(),
"codex.code_mode".to_string(),
],
host_capabilities: HostCapabilities::from_names([
"codex.inline_visualization",
"codex.code_mode",
]),
}
);
}
@@ -788,7 +780,7 @@ mod tests {
assert_eq!(manifest.version, Some("2.0.0".to_string()));
assert_eq!(
manifest.requires.host_capabilities,
vec!["codex.inline_visualization".to_string()]
HostCapabilities::from_names(["codex.inline_visualization"])
);
assert_eq!(
manifest
@@ -897,7 +889,7 @@ mod tests {
assert_eq!(
executor_plugin.manifest().requires,
PluginManifestRequirements {
host_capabilities: vec!["codex.inline_visualization".to_string()],
host_capabilities: HostCapabilities::from_names(["codex.inline_visualization"]),
}
);
assert_eq!(executor_plugin, expected_plugin);
+19
View File
@@ -13,9 +13,11 @@ use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_plugin::AppConnectorId;
use codex_plugin::AppDeclaration;
use codex_plugin::HostCapabilities;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginId;
use codex_plugin::app_connector_ids_from_declarations;
use codex_plugin::manifest::PluginManifestRequirements;
use codex_plugin::prompt_safe_plugin_description;
use codex_utils_absolute_path::AbsolutePathBuf;
use reqwest::RequestBuilder;
@@ -263,6 +265,7 @@ pub struct RecommendedPlugin {
pub remote_plugin_id: String,
pub display_name: String,
pub app_connector_ids: Vec<String>,
pub requirements: PluginManifestRequirements,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -648,6 +651,15 @@ struct RecommendedPluginRelease {
display_name: String,
#[serde(default)]
app_ids: Vec<String>,
#[serde(default)]
requires: Option<RecommendedPluginRequirements>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RecommendedPluginRequirements {
#[serde(default)]
host_capabilities: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -894,7 +906,11 @@ fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> Recommended
let RecommendedPluginRelease {
display_name,
app_ids,
requires,
} = plugin.release;
let Some(requires) = requires else {
return RecommendedPluginsMode::Legacy;
};
let display_name = non_empty_string(Some(&display_name))
.unwrap_or_else(|| plugin.name.clone())
.chars()
@@ -913,6 +929,9 @@ fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> Recommended
remote_plugin_id: plugin.id,
display_name,
app_connector_ids,
requirements: PluginManifestRequirements {
host_capabilities: HostCapabilities::from_names(requires.host_capabilities),
},
});
}
+23
View File
@@ -164,6 +164,7 @@ fn item(name: &str, display_name: &str) -> RecommendedPluginItem {
release: RecommendedPluginRelease {
display_name: display_name.to_string(),
app_ids: Vec::new(),
requires: Some(RecommendedPluginRequirements::default()),
},
}
}
@@ -218,6 +219,20 @@ fn recommended_plugins_require_remote_install_identity() {
assert!(response.is_err());
}
#[test]
fn recommended_plugins_without_requirements_use_legacy_discovery() {
let mut plugin = item("github", "GitHub");
plugin.release.requires = None;
assert_eq!(
recommended_plugins_mode(RecommendedPluginsResponse {
enabled: Some(true),
plugins: vec![plugin],
}),
RecommendedPluginsMode::Legacy
);
}
#[test]
fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
let mut plugins = (0..=52)
@@ -234,6 +249,7 @@ fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
release: RecommendedPluginRelease {
display_name: "Disabled".to_string(),
app_ids: Vec::new(),
requires: Some(RecommendedPluginRequirements::default()),
},
});
plugins.push(RecommendedPluginItem {
@@ -244,6 +260,7 @@ fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
release: RecommendedPluginRelease {
display_name: "Not Available".to_string(),
app_ids: Vec::new(),
requires: Some(RecommendedPluginRequirements::default()),
},
});
@@ -263,6 +280,7 @@ fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
remote_plugin_id: "plugin_plugin-00".to_string(),
display_name: "Plugin 00".to_string(),
app_connector_ids: Vec::new(),
requirements: PluginManifestRequirements::default(),
})
);
assert_eq!(
@@ -272,6 +290,7 @@ fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
remote_plugin_id: "plugin_plugin-49".to_string(),
display_name: "Plugin 49".to_string(),
app_connector_ids: Vec::new(),
requirements: PluginManifestRequirements::default(),
})
);
}
@@ -296,6 +315,7 @@ fn recommended_plugins_bound_model_visible_fields() {
remote_plugin_id: "plugin_bounded".to_string(),
display_name: "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN),
app_connector_ids: Vec::new(),
requirements: PluginManifestRequirements::default(),
}],
}
);
@@ -318,6 +338,7 @@ fn recommended_plugins_preserve_install_identity_and_normalize_app_ids() {
"connector_two".to_string(),
"connector_one".to_string(),
],
requires: Some(RecommendedPluginRequirements::default()),
},
}],
});
@@ -330,6 +351,7 @@ fn recommended_plugins_preserve_install_identity_and_normalize_app_ids() {
remote_plugin_id: "plugin_connector_sample".to_string(),
display_name: "Sample".to_string(),
app_connector_ids: vec!["connector_one".to_string(), "connector_two".to_string(),],
requirements: PluginManifestRequirements::default(),
}],
}
);
@@ -347,6 +369,7 @@ fn recommended_plugins_ignore_invalid_remote_plugin_ids() {
release: RecommendedPluginRelease {
display_name: "Sample".to_string(),
app_ids: Vec::new(),
requires: Some(RecommendedPluginRequirements::default()),
},
}],
});
@@ -1,14 +1,15 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::RwLock;
use codex_core_skills::config_rules::SkillConfigRules;
use codex_plugin::AppDeclaration;
use codex_plugin::HostCapabilities;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
use codex_plugin::app_connector_ids_from_declarations;
use codex_plugin::manifest::PluginManifestRequirements;
use codex_plugin::prompt_safe_plugin_description;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::Product;
@@ -55,7 +56,7 @@ pub(crate) struct ToolSuggestMetadataFragment {
config_name: String,
display_name: String,
description: Option<String>,
required_host_capabilities: Vec<String>,
requirements: PluginManifestRequirements,
mcp_server_names: Vec<String>,
app_declarations: Vec<AppDeclaration>,
skill_inventory: Option<PluginSkillInventory>,
@@ -66,13 +67,9 @@ impl ToolSuggestMetadataFragment {
&self,
skill_config_rules: &SkillConfigRules,
auth_mode: Option<AuthMode>,
host_capabilities: &HashSet<String>,
host_capabilities: &HostCapabilities,
) -> Option<PluginCapabilitySummary> {
if self
.required_host_capabilities
.iter()
.any(|capability| !host_capabilities.contains(capability))
{
if !self.requirements.is_satisfied_by(host_capabilities) {
return None;
}
@@ -206,15 +203,14 @@ async fn load_plugin_metadata(
)?;
let MarketplacePluginSource::Local { path: plugin_root } = &plugin.source else {
let required_host_capabilities = match plugin.manifest_fallback.as_ref() {
let requirements = match plugin.manifest_fallback.as_ref() {
Some(fallback) => {
fallback
.parse_for_listing()
.ok_or_else(|| "invalid marketplace plugin manifest fallback".to_string())?
.requires
.host_capabilities
}
None => Vec::new(),
None => PluginManifestRequirements::default(),
};
return Ok(Arc::new(ToolSuggestMetadataFragment {
config_name: plugin.id.clone(),
@@ -222,7 +218,7 @@ async fn load_plugin_metadata(
description: prompt_safe_plugin_description(Some(
&remote_plugin_install_required_description(&plugin.source),
)),
required_host_capabilities,
requirements,
mcp_server_names: Vec::new(),
app_declarations: Vec::new(),
skill_inventory: None,
@@ -254,7 +250,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,
requirements: manifest.requires,
mcp_server_names,
app_declarations,
skill_inventory: Some(skill_inventory),
+8 -23
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<CwdSkillsCacheKey, HostSkillsSnapshot>>,
cache_by_cwd: RwLock<HashMap<AbsolutePathBuf, HostSkillsSnapshot>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, HostSkillsSnapshot>>,
}
@@ -173,17 +173,9 @@ 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(&cache_key)
&& let Some(snapshot) = self.cached_snapshot_for_cwd(&input.cwd)
{
return snapshot;
}
@@ -196,9 +188,10 @@ impl SkillsService {
self.extra_roots(),
)
.await;
if !bundled_skills_enabled {
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
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,
@@ -208,7 +201,7 @@ impl SkillsService {
.cache_by_cwd
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(cache_key, snapshot.clone());
cache.insert(input.cwd.clone(), snapshot.clone());
}
snapshot
}
@@ -250,10 +243,10 @@ impl SkillsService {
info!("skills cache cleared ({cleared} entries)");
}
fn cached_snapshot_for_cwd(&self, cache_key: &CwdSkillsCacheKey) -> Option<HostSkillsSnapshot> {
fn cached_snapshot_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option<HostSkillsSnapshot> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cache_key).cloned(),
Err(err) => err.into_inner().get(cache_key).cloned(),
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
}
}
@@ -275,14 +268,6 @@ 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,68 +228,6 @@ 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,7 +205,6 @@ 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,7 +181,6 @@ 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",
@@ -203,10 +202,6 @@ 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,7 +55,6 @@ 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)]
@@ -81,7 +80,6 @@ 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
+1 -11
View File
@@ -4741,12 +4741,7 @@ 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",
"requires": {
"hostCapabilities": ["codex.inline_visualization"]
}
}"#,
r#"{"name":"sample"}"#,
)?;
std::fs::write(
plugin_root.join(".mcp.json"),
@@ -4813,7 +4808,6 @@ 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(),
@@ -4827,10 +4821,6 @@ 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"))
+2 -19
View File
@@ -120,7 +120,6 @@ 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;
@@ -611,9 +610,6 @@ 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 {
@@ -624,12 +620,6 @@ 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>,
@@ -1508,14 +1498,12 @@ impl Config {
/// Build the plugin-manager input from the effective config.
pub fn plugins_config_input(&self) -> PluginsConfigInput {
let mut input = PluginsConfigInput::new(
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.
@@ -1689,7 +1677,6 @@ 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(),
@@ -2439,8 +2426,6 @@ 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>) {
@@ -3031,7 +3016,6 @@ 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();
@@ -3815,7 +3799,6 @@ 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,7 +50,6 @@ struct AccessibleConnectorsCacheKey {
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
host_capabilities: crate::config::HostCapabilities,
}
#[derive(Clone)]
@@ -360,7 +359,6 @@ fn accessible_connectors_cache_key(
account_id,
chatgpt_user_id,
is_workspace_account,
host_capabilities: config.host_capabilities.clone(),
}
}
-10
View File
@@ -224,12 +224,6 @@ 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",
@@ -247,10 +241,6 @@ 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,7 +199,6 @@ 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(),
}
}
-104
View File
@@ -29,7 +29,6 @@ use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use wiremock::MockServer;
@@ -275,109 +274,6 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn host_capabilities_gate_plugin_guidance_and_tools_all_of() -> Result<()> {
skip_if_no_network!(Ok(()));
let rmcp_test_server_bin = match stdio_server_bin() {
Ok(bin) => bin,
Err(err) => {
eprintln!("test_stdio_server binary not available, skipping test: {err}");
return Ok(());
}
};
for (host_capabilities, expected_supported) in [
(Vec::new(), false),
(vec!["capability-a"], false),
(vec!["capability-b"], false),
(vec!["capability-a", "capability-b"], true),
(
vec!["capability-a", "capability-b", "capability-extra"],
true,
),
] {
let server = start_mock_server().await;
let mock = mount_plugin_tool_search_turn(&server).await;
let codex_home = Arc::new(TempDir::new()?);
write_plugin_skill_plugin(codex_home.as_ref());
write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin);
std::fs::write(
sample_plugin_root(codex_home.as_ref()).join(".codex-plugin/plugin.json"),
format!(
r#"{{
"name": "{SAMPLE_PLUGIN_DISPLAY_NAME}",
"description": "{SAMPLE_PLUGIN_DESCRIPTION}",
"requires": {{
"hostCapabilities": ["capability-a", "capability-b"]
}}
}}"#
),
)?;
let mut builder = test_codex()
.with_home(codex_home)
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.host_capabilities = host_capabilities
.iter()
.map(std::string::ToString::to_string)
.collect();
});
let test_codex = builder
.build(&server)
.await
.expect("create new conversation");
let codex = Arc::clone(&test_codex.codex);
let startup = loop {
let event = codex
.next_event()
.await
.expect("stream ended unexpectedly while waiting for MCP startup");
if let EventMsg::McpStartupComplete(startup) = event.msg {
break startup;
}
};
let sample_ready = startup.ready.iter().any(|server| server == "sample");
let sample_reported = sample_ready
|| startup
.failed
.iter()
.any(|failure| failure.server == "sample")
|| startup.cancelled.iter().any(|server| server == "sample");
assert_eq!(sample_reported, expected_supported);
assert_eq!(sample_ready, expected_supported);
codex
.submit(Op::UserInput {
items: vec![codex_protocol::user_input::UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let requests = mock.requests();
let developer_text = requests[0].message_input_texts("developer").join("\n\n");
assert_eq!(
developer_text.contains("sample:sample-search: inspect sample data"),
expected_supported
);
assert_eq!(
developer_text.contains(SAMPLE_PLUGIN_DESCRIPTION),
expected_supported
);
let (_, echo_tool) = searched_plugin_tools(&requests[1]);
assert_eq!(echo_tool.is_some(), expected_supported);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> {
skip_if_no_network!(Ok(()));
-2
View File
@@ -446,7 +446,6 @@ 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| {
@@ -574,7 +573,6 @@ 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,7 +24,6 @@ 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>,
}
@@ -113,12 +112,6 @@ 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,
})
@@ -178,13 +171,6 @@ 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,7 +17,6 @@ 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;
@@ -401,7 +400,6 @@ fn resolved_plugin(
version: None,
description: None,
keywords: Vec::new(),
requires: PluginManifestRequirements::default(),
paths: PluginManifestPaths {
skills: Vec::new(),
mcp_servers,
@@ -147,104 +147,6 @@ 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,7 +15,6 @@ 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,6 +1,5 @@
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)]
@@ -118,7 +117,6 @@ pub struct SkillCatalogEntry {
pub dependencies: Option<SkillDependencies>,
pub enabled: bool,
pub prompt_visible: bool,
pub(crate) required_host_capabilities: Vec<String>,
}
impl SkillCatalogEntry {
@@ -140,7 +138,6 @@ impl SkillCatalogEntry {
dependencies: None,
enabled: true,
prompt_visible: true,
required_host_capabilities: Vec::new(),
}
}
@@ -169,21 +166,6 @@ 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()
@@ -196,9 +178,6 @@ 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 {
@@ -207,8 +186,6 @@ 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) {
@@ -222,33 +199,6 @@ 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,5 +1,3 @@
use std::collections::BTreeSet;
/// Host-supplied configuration used by the skills extension.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SkillsExtensionConfig {
@@ -9,6 +7,4 @@ 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,7 +123,6 @@ 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,
)
@@ -161,7 +160,6 @@ 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;
@@ -233,7 +231,6 @@ 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,4 +1,3 @@
use std::collections::BTreeSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -32,7 +31,6 @@ 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)]
+6 -37
View File
@@ -1,6 +1,5 @@
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;
@@ -27,7 +26,6 @@ use crate::provider::SkillSearchRequest;
#[derive(Clone, Debug)]
pub struct ExecutorSkillProvider {
environment_manager: Arc<EnvironmentManager>,
plugin_provider: ExecutorPluginProvider,
restriction_product: Option<Product>,
}
@@ -37,7 +35,6 @@ impl ExecutorSkillProvider {
restriction_product: Option<Product>,
) -> Self {
Self {
plugin_provider: ExecutorPluginProvider::new(Arc::clone(&environment_manager)),
environment_manager,
restriction_product,
}
@@ -49,26 +46,7 @@ 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.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 selected_root_id = selected_root.id;
let CapabilityRootLocation::Environment {
environment_id,
path,
@@ -77,12 +55,9 @@ impl SkillProvider for ExecutorSkillProvider {
SkillAuthority::new(SkillSourceKind::Executor, selected_root_id.clone());
let Some(environment) = self.environment_manager.get_environment(&environment_id)
else {
catalog.extend_warnings_with_host_requirements(
vec![format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
)],
&required_host_capabilities,
);
catalog.warnings.push(format!(
"Selected capability root `{selected_root_id}` references unavailable environment `{environment_id}`."
));
continue;
};
let file_system = environment.get_filesystem();
@@ -92,17 +67,13 @@ impl SkillProvider for ExecutorSkillProvider {
self.restriction_product,
)
.await;
catalog.extend_warnings_with_host_requirements(
outcome.warnings,
&required_host_capabilities,
);
catalog.warnings.extend(outcome.warnings);
for skill in outcome.skills {
catalog.push_entry(catalog_entry_from_skill(
&skill,
authority.clone(),
&selected_root_id,
&environment_id,
&required_host_capabilities,
));
}
}
@@ -162,7 +133,6 @@ 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() {
@@ -186,8 +156,7 @@ fn catalog_entry_from_skill(
)
.with_short_description(skill.short_description.clone())
.with_display_path(display_path)
.with_dependencies(skill.dependencies.clone())
.with_required_host_capabilities(required_host_capabilities.to_vec());
.with_dependencies(skill.dependencies.clone());
if skill.allows_implicit_invocation() {
entry
-1
View File
@@ -95,7 +95,6 @@ fn catalog_from_outcome(outcome: &SkillLoadOutcome) -> SkillCatalog {
)
})
.collect(),
..Default::default()
};
for (skill, enabled) in outcome.skills_with_enabled() {
+4 -17
View File
@@ -78,26 +78,13 @@ 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()];
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.extend(
self.executor_root_catalog(providers, root, query.clone())
.await,
);
}
catalog
}
-1
View File
@@ -73,7 +73,6 @@ 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,7 +265,6 @@ 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");
@@ -300,79 +299,6 @@ 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,7 +158,6 @@ 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)),
@@ -336,196 +335,6 @@ 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);
@@ -541,7 +350,6 @@ 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,
@@ -592,7 +400,6 @@ 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,
@@ -663,7 +470,6 @@ 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)),
@@ -745,7 +551,6 @@ 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,
@@ -846,7 +651,6 @@ 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,
@@ -977,7 +781,6 @@ struct TestConfig {
include_instructions: bool,
bundled_skills_enabled: bool,
orchestrator_skills_enabled: bool,
host_capabilities: std::collections::BTreeSet<String>,
}
fn default_config() -> TestConfig {
@@ -985,7 +788,6 @@ fn default_config() -> TestConfig {
include_instructions: true,
bundled_skills_enabled: true,
orchestrator_skills_enabled: true,
host_capabilities: Default::default(),
}
}
@@ -994,7 +796,6 @@ 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(),
}
}
+47
View File
@@ -0,0 +1,47 @@
use std::collections::BTreeSet;
/// Opaque host features available to runtime plugin contributions.
///
/// Capability names are case-sensitive. Construction trims surrounding
/// whitespace, drops empty names, and deduplicates values deterministically.
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
pub struct HostCapabilities(BTreeSet<String>);
impl HostCapabilities {
pub fn from_names<I, S>(names: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
Self(
names
.into_iter()
.filter_map(|name| {
let name = name.as_ref().trim();
(!name.is_empty()).then(|| name.to_string())
})
.collect(),
)
}
pub fn contains(&self, capability: &str) -> bool {
self.0.contains(capability)
}
pub fn insert(&mut self, capability: impl AsRef<str>) -> bool {
let capability = capability.as_ref().trim();
!capability.is_empty() && self.0.insert(capability.to_string())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
}
#[cfg(test)]
#[path = "host_capabilities_tests.rs"]
mod tests;
@@ -0,0 +1,14 @@
use pretty_assertions::assert_eq;
use super::HostCapabilities;
#[test]
fn capability_names_are_normalized_deterministically() {
let capabilities =
HostCapabilities::from_names([" capability-b ", "", "capability-a", "capability-b"]);
assert_eq!(
capabilities.iter().collect::<Vec<_>>(),
vec!["capability-a", "capability-b"]
);
}
+2
View File
@@ -5,6 +5,7 @@ use std::collections::HashSet;
pub use codex_utils_plugins::mention_syntax;
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
mod host_capabilities;
mod load_outcome;
pub mod manifest;
mod plugin_id;
@@ -12,6 +13,7 @@ mod provider;
use codex_config::HookEventsToml;
use codex_utils_absolute_path::AbsolutePathBuf;
pub use host_capabilities::HostCapabilities;
pub use load_outcome::EffectiveSkillRoots;
pub use load_outcome::LoadedPlugin;
pub use load_outcome::PluginLoadOutcome;
+11 -11
View File
@@ -6,9 +6,11 @@ use codex_utils_plugins::PluginSkillRoot;
use crate::AppConnectorId;
use crate::AppDeclaration;
use crate::HostCapabilities;
use crate::PluginCapabilitySummary;
use crate::PluginHookSource;
use crate::app_connector_ids_from_declarations;
use crate::manifest::PluginManifestRequirements;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -21,8 +23,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 required_host_capabilities: HostCapabilities,
pub missing_host_capabilities: HostCapabilities,
pub skill_roots: Vec<AbsolutePathBuf>,
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
pub has_enabled_skills: bool,
@@ -42,13 +44,11 @@ impl<M> LoadedPlugin<M> {
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<_>>();
pub fn apply_host_capabilities(&mut self, host_capabilities: &HostCapabilities) {
self.missing_host_capabilities = PluginManifestRequirements {
host_capabilities: self.required_host_capabilities.clone(),
}
.missing_from(host_capabilities);
}
}
@@ -243,8 +243,8 @@ mod tests {
manifest_description: None,
root: test_path(config_name),
enabled: true,
required_host_capabilities: Vec::new(),
missing_host_capabilities: Vec::new(),
required_host_capabilities: HostCapabilities::default(),
missing_host_capabilities: HostCapabilities::default(),
skill_roots,
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
+19 -1
View File
@@ -1,5 +1,7 @@
use codex_config::HooksFile;
use crate::HostCapabilities;
/// Parsed plugin metadata parameterized by its resource locator representation.
///
/// Host loading uses absolute paths, while resolved packages replace them with
@@ -19,7 +21,23 @@ pub struct PluginManifest<Resource> {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestRequirements {
/// Every listed capability must be advertised by the host.
pub host_capabilities: Vec<String>,
pub host_capabilities: HostCapabilities,
}
impl PluginManifestRequirements {
pub fn is_satisfied_by(&self, host_capabilities: &HostCapabilities) -> bool {
self.host_capabilities
.iter()
.all(|capability| host_capabilities.contains(capability))
}
pub fn missing_from(&self, host_capabilities: &HostCapabilities) -> HostCapabilities {
HostCapabilities::from_names(
self.host_capabilities
.iter()
.filter(|capability| !host_capabilities.contains(capability)),
)
}
}
/// Component resources declared by a plugin manifest.
+3 -2
View File
@@ -1,6 +1,7 @@
use super::PluginResourceLocator;
use super::ResolvedPlugin;
use super::ResolvedPluginError;
use crate::HostCapabilities;
use crate::manifest::PluginManifest;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestInterface;
@@ -44,7 +45,7 @@ fn environment_descriptor_binds_every_manifest_resource() {
description: None,
keywords: Vec::new(),
requires: PluginManifestRequirements {
host_capabilities: vec!["codex.inline_visualization".to_string()],
host_capabilities: HostCapabilities::from_names(["codex.inline_visualization"]),
},
paths: PluginManifestPaths {
skills: vec![path_uri(&skills)],
@@ -81,7 +82,7 @@ fn environment_descriptor_binds_every_manifest_resource() {
description: None,
keywords: Vec::new(),
requires: PluginManifestRequirements {
host_capabilities: vec!["codex.inline_visualization".to_string()],
host_capabilities: HostCapabilities::from_names(["codex.inline_visualization"]),
},
paths: PluginManifestPaths {
skills: vec![resource("executor-1", &skills)],
@@ -176,7 +176,6 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
let mut config = Config {
config_layer_stack: ConfigLayerStack::default(),
startup_warnings: Vec::new(),
host_capabilities: Default::default(),
bypass_hook_trust: false,
model,
service_tier: None,
-2
View File
@@ -400,7 +400,6 @@ 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,
})
@@ -565,7 +564,6 @@ 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,7 +1069,6 @@ 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,
})