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