mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[mcp] Fix plugin MCP approval policy. (#19537)
Plugin MCP servers are loaded from plugin manifests rather than top-level `[mcp_servers]`, so their tool approval preferences need to be stored and applied through the owning plugin config. Without this, choosing "Always allow" for a plugin MCP tool could write a preference that was not reliably used on later tool calls. ## Summary - Add plugin-scoped MCP policy config under `plugins.<plugin>.mcp_servers`, including server enablement, tool allow/deny lists, server defaults, and per-tool approval modes. - Overlay plugin MCP policy onto manifest-provided server configs when plugins are loaded. - Route persistent "Always allow" writes for plugin MCP tools back to the owning `plugins.<plugin>.mcp_servers.<server>.tools.<tool>` config entry. - Reload user config after persisting an approval and make the plugin load cache config-aware so stale plugin MCP policy is not reused after `config.toml` changes. - Regenerate the config schema and add coverage for plugin MCP policy loading, approval lookup, persistence, and stale-cache prevention. ## Testing - `cargo test -p codex-config` - `cargo test -p codex-core-plugins` - `cargo test -p codex-core --lib plugin_mcp`
This commit is contained in:
committed by
GitHub
Unverified
parent
4241df4d79
commit
e20391e567
@@ -542,6 +542,7 @@ mod tests {
|
||||
},
|
||||
}),
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(CoreResidencyRequirement::Us),
|
||||
@@ -670,6 +671,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -730,6 +732,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
@@ -1207,6 +1207,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1288,6 +1289,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1320,6 +1322,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1369,6 +1372,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1403,6 +1407,36 @@ enabled = false
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_parses_plugin_mcp_requirements_toml() {
|
||||
let result = parse_for_fetch(Some(
|
||||
r#"
|
||||
[plugins."sample@test".mcp_servers.sample.identity]
|
||||
command = "sample-mcp"
|
||||
"#,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(ConfigRequirementsToml {
|
||||
plugins: Some(BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
codex_config::PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"sample".to_string(),
|
||||
codex_config::McpServerRequirement {
|
||||
identity: codex_config::McpServerIdentity::Command {
|
||||
command: "sample-mcp".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fetch_cloud_requirements_times_out() {
|
||||
let auth_manager = auth_manager_with_plan("enterprise").await;
|
||||
@@ -1454,6 +1488,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1533,6 +1568,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1610,6 +1646,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1815,6 +1852,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1854,6 +1892,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1913,6 +1952,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1968,6 +2008,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2025,6 +2066,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2083,6 +2125,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2141,6 +2184,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2229,6 +2273,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2259,6 +2304,7 @@ enabled = false
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct ConfigRequirements {
|
||||
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
||||
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
|
||||
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
|
||||
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
|
||||
/// Managed network constraints derived from requirements.
|
||||
@@ -121,6 +122,7 @@ impl Default for ConfigRequirements {
|
||||
feature_requirements: None,
|
||||
managed_hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
exec_policy: None,
|
||||
enforce_residency: ConstrainedWithSource::new(
|
||||
Constrained::allow_any(/*initial_value*/ None),
|
||||
@@ -151,6 +153,17 @@ pub struct McpServerRequirement {
|
||||
pub identity: McpServerIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginRequirementsToml {
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
}
|
||||
|
||||
impl PluginRequirementsToml {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkDomainPermissionsToml {
|
||||
#[serde(flatten)]
|
||||
@@ -633,6 +646,7 @@ pub struct ConfigRequirementsToml {
|
||||
pub feature_requirements: Option<FeatureRequirementsToml>,
|
||||
pub hooks: Option<ManagedHooksRequirementsToml>,
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
pub plugins: Option<BTreeMap<String, PluginRequirementsToml>>,
|
||||
pub apps: Option<AppsRequirementsToml>,
|
||||
pub rules: Option<RequirementsExecPolicyToml>,
|
||||
pub enforce_residency: Option<ResidencyRequirement>,
|
||||
@@ -679,6 +693,7 @@ pub struct ConfigRequirementsWithSources {
|
||||
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
|
||||
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
|
||||
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
|
||||
pub apps: Option<Sourced<AppsRequirementsToml>>,
|
||||
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
|
||||
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
|
||||
@@ -714,6 +729,7 @@ impl ConfigRequirementsWithSources {
|
||||
feature_requirements: _,
|
||||
hooks: _,
|
||||
mcp_servers: _,
|
||||
plugins: _,
|
||||
apps: _,
|
||||
rules: _,
|
||||
enforce_residency: _,
|
||||
@@ -742,6 +758,7 @@ impl ConfigRequirementsWithSources {
|
||||
feature_requirements,
|
||||
hooks,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
rules,
|
||||
enforce_residency,
|
||||
network,
|
||||
@@ -768,6 +785,7 @@ impl ConfigRequirementsWithSources {
|
||||
feature_requirements,
|
||||
hooks,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
apps,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -784,6 +802,7 @@ impl ConfigRequirementsWithSources {
|
||||
feature_requirements: feature_requirements.map(|sourced| sourced.value),
|
||||
hooks: hooks.map(|sourced| sourced.value),
|
||||
mcp_servers: mcp_servers.map(|sourced| sourced.value),
|
||||
plugins: plugins.map(|sourced| sourced.value),
|
||||
apps: apps.map(|sourced| sourced.value),
|
||||
rules: rules.map(|sourced| sourced.value),
|
||||
enforce_residency: enforce_residency.map(|sourced| sourced.value),
|
||||
@@ -872,6 +891,10 @@ impl ConfigRequirementsToml {
|
||||
.as_ref()
|
||||
.is_none_or(ManagedHooksRequirementsToml::is_empty)
|
||||
&& self.mcp_servers.is_none()
|
||||
&& self
|
||||
.plugins
|
||||
.as_ref()
|
||||
.is_none_or(|plugins| plugins.values().all(PluginRequirementsToml::is_empty))
|
||||
&& self
|
||||
.apps
|
||||
.as_ref()
|
||||
@@ -899,6 +922,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
feature_requirements,
|
||||
hooks,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
apps: _apps,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -1133,6 +1157,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
feature_requirements,
|
||||
managed_hooks,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
exec_policy,
|
||||
enforce_residency,
|
||||
network,
|
||||
@@ -1204,6 +1229,7 @@ mod tests {
|
||||
feature_requirements,
|
||||
hooks,
|
||||
mcp_servers,
|
||||
plugins,
|
||||
apps,
|
||||
rules,
|
||||
enforce_residency,
|
||||
@@ -1224,6 +1250,7 @@ mod tests {
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
enforce_residency: enforce_residency
|
||||
@@ -1269,6 +1296,7 @@ mod tests {
|
||||
feature_requirements: Some(feature_requirements.clone()),
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(enforce_residency),
|
||||
@@ -1301,6 +1329,7 @@ mod tests {
|
||||
)),
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
|
||||
@@ -1339,6 +1368,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1385,6 +1415,7 @@ mod tests {
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -2700,6 +2731,55 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_plugin_mcp_server_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[plugins."sample@test".mcp_servers.sample.identity]
|
||||
command = "sample-mcp"
|
||||
|
||||
[plugins."remote@test".mcp_servers.remote.identity]
|
||||
url = "https://example.com/mcp"
|
||||
"#;
|
||||
let requirements: ConfigRequirements =
|
||||
with_unknown_source(from_str(toml_str)?).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.plugins,
|
||||
Some(Sourced::new(
|
||||
BTreeMap::from([
|
||||
(
|
||||
"remote@test".to_string(),
|
||||
PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
),
|
||||
(
|
||||
"sample@test".to_string(),
|
||||
PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "sample-mcp".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
),
|
||||
]),
|
||||
RequirementSource::Unknown,
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_exec_policy_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -48,6 +48,7 @@ pub use config_requirements::NetworkDomainPermissionsToml;
|
||||
pub use config_requirements::NetworkRequirementsToml;
|
||||
pub use config_requirements::NetworkUnixSocketPermissionToml;
|
||||
pub use config_requirements::NetworkUnixSocketPermissionsToml;
|
||||
pub use config_requirements::PluginRequirementsToml;
|
||||
pub use config_requirements::RemoteSandboxConfigToml;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::ResidencyRequirement;
|
||||
|
||||
@@ -717,6 +717,50 @@ pub use crate::skills_config::SkillsConfig;
|
||||
pub struct PluginConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Per-MCP-server policy overlays for MCP servers contributed by this plugin.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub mcp_servers: HashMap<String, PluginMcpServerConfig>,
|
||||
}
|
||||
|
||||
/// Policy settings for a plugin-provided MCP server.
|
||||
///
|
||||
/// This intentionally excludes transport settings: plugin manifests own how the
|
||||
/// MCP server is launched, while user config owns enablement and tool policy.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct PluginMcpServerConfig {
|
||||
/// When `false`, Codex skips initializing this plugin MCP server.
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Approval mode for tools in this server unless a tool override exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_tools_approval_mode: Option<AppToolApproval>,
|
||||
|
||||
/// Explicit allow-list of tools exposed from this server.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub enabled_tools: Option<Vec<String>>,
|
||||
|
||||
/// Explicit deny-list of tools. These tools are removed after applying `enabled_tools`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
|
||||
/// Per-tool approval settings keyed by tool name.
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub tools: HashMap<String, McpServerToolConfig>,
|
||||
}
|
||||
|
||||
impl Default for PluginMcpServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_config::ConfigLayerStack;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::types::PluginMcpServerConfig;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_core_skills::config_rules::SkillConfigRules;
|
||||
use codex_core_skills::config_rules::resolve_disabled_skill_paths;
|
||||
@@ -175,6 +176,7 @@ pub fn remote_installed_plugins_to_config(
|
||||
plugin_id.as_key(),
|
||||
PluginConfig {
|
||||
enabled: plugin.enabled,
|
||||
mcp_servers: HashMap::new(),
|
||||
},
|
||||
))
|
||||
})
|
||||
@@ -575,7 +577,10 @@ async fn load_plugin(
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await;
|
||||
for (name, config) in plugin_mcp.mcp_servers {
|
||||
for (name, mut config) in plugin_mcp.mcp_servers {
|
||||
if let Some(policy) = plugin.mcp_servers.get(&name) {
|
||||
apply_plugin_mcp_server_policy(&mut config, policy);
|
||||
}
|
||||
if mcp_servers.insert(name.clone(), config).is_some() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
@@ -599,6 +604,25 @@ async fn load_plugin(
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
fn apply_plugin_mcp_server_policy(config: &mut McpServerConfig, policy: &PluginMcpServerConfig) {
|
||||
config.enabled = policy.enabled;
|
||||
if let Some(approval_mode) = policy.default_tools_approval_mode {
|
||||
config.default_tools_approval_mode = Some(approval_mode);
|
||||
}
|
||||
if let Some(enabled_tools) = &policy.enabled_tools {
|
||||
config.enabled_tools = Some(enabled_tools.clone());
|
||||
}
|
||||
if let Some(disabled_tools) = &policy.disabled_tools {
|
||||
config.disabled_tools = Some(disabled_tools.clone());
|
||||
}
|
||||
for (tool_name, tool_policy) in &policy.tools {
|
||||
let tool_config = config.tools.entry(tool_name.clone()).or_default();
|
||||
if let Some(approval_mode) = tool_policy.approval_mode {
|
||||
tool_config.approval_mode = Some(approval_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPluginSkills {
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
|
||||
@@ -1760,6 +1760,54 @@
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"mcp_servers": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/PluginMcpServerConfig"
|
||||
},
|
||||
"description": "Per-MCP-server policy overlays for MCP servers contributed by this plugin.",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PluginMcpServerConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Policy settings for a plugin-provided MCP server.\n\nThis intentionally excludes transport settings: plugin manifests own how the MCP server is launched, while user config owns enablement and tool policy.",
|
||||
"properties": {
|
||||
"default_tools_approval_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppToolApproval"
|
||||
}
|
||||
],
|
||||
"description": "Approval mode for tools in this server unless a tool override exists."
|
||||
},
|
||||
"disabled_tools": {
|
||||
"description": "Explicit deny-list of tools. These tools are removed after applying `enabled_tools`.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"description": "When `false`, Codex skips initializing this plugin MCP server.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"enabled_tools": {
|
||||
"description": "Explicit allow-list of tools exposed from this server.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"tools": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/McpServerToolConfig"
|
||||
},
|
||||
"description": "Per-tool approval settings keyed by tool name.",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -780,6 +780,7 @@ allowed_approval_policies = ["on-request"]
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -836,6 +837,7 @@ allowed_approval_policies = ["on-request"]
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -1043,6 +1045,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
@@ -2404,6 +2404,258 @@ fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
|
||||
const MATCHED_SERVER: &str = "matched-should-allow";
|
||||
const MISMATCHED_SERVER: &str = "mismatched-should-disable";
|
||||
const UNLISTED_SERVER: &str = "unlisted-should-disable";
|
||||
const GOOD_CMD: &str = "good-cmd";
|
||||
|
||||
let mut servers = HashMap::from([
|
||||
(MATCHED_SERVER.to_string(), stdio_mcp(GOOD_CMD)),
|
||||
(MISMATCHED_SERVER.to_string(), stdio_mcp("bad-cmd")),
|
||||
(
|
||||
UNLISTED_SERVER.to_string(),
|
||||
http_mcp("https://example.com/mcp"),
|
||||
),
|
||||
]);
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let requirements = Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
codex_config::PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([
|
||||
(
|
||||
MATCHED_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: GOOD_CMD.to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
(
|
||||
MISMATCHED_SERVER.to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: GOOD_CMD.to_string(),
|
||||
},
|
||||
},
|
||||
),
|
||||
])),
|
||||
},
|
||||
)]),
|
||||
source.clone(),
|
||||
);
|
||||
|
||||
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
|
||||
|
||||
let reason = Some(McpServerDisabledReason::Requirements { source });
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (
|
||||
name.clone(),
|
||||
(server.enabled, server.disabled_reason.clone())
|
||||
))
|
||||
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
|
||||
HashMap::from([
|
||||
(MATCHED_SERVER.to_string(), (true, None)),
|
||||
(MISMATCHED_SERVER.to_string(), (false, reason.clone())),
|
||||
(UNLISTED_SERVER.to_string(), (false, reason)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
|
||||
let mut servers = HashMap::from([("server-a".to_string(), stdio_mcp("cmd-a"))]);
|
||||
let source = RequirementSource::CloudRequirements;
|
||||
let requirements = Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"other@test".to_string(),
|
||||
codex_config::PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"server-a".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "cmd-a".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
)]),
|
||||
source.clone(),
|
||||
);
|
||||
|
||||
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
|
||||
|
||||
assert_eq!(
|
||||
servers
|
||||
.iter()
|
||||
.map(|(name, server)| (
|
||||
name.clone(),
|
||||
(server.enabled, server.disabled_reason.clone())
|
||||
))
|
||||
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
|
||||
HashMap::from([(
|
||||
"server-a".to_string(),
|
||||
(
|
||||
false,
|
||||
Some(McpServerDisabledReason::Requirements { source })
|
||||
)
|
||||
)])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://sample.example/mcp"
|
||||
},
|
||||
"unlisted": {
|
||||
"type": "http",
|
||||
"url": "https://unlisted.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
plugins: Some(BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
codex_config::PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerRequirement {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://sample.example/mcp".to_string(),
|
||||
},
|
||||
},
|
||||
)])),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
.get("sample")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((true, None))
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
.get("unlisted")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((
|
||||
false,
|
||||
Some(McpServerDisabledReason::Requirements {
|
||||
source: RequirementSource::CloudRequirements,
|
||||
})
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn to_mcp_config_empty_mcp_requirements_disable_plugin_mcps() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://sample.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements = codex_config::ConfigRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.cloud_requirements(CloudRequirementsLoader::new(async move {
|
||||
Ok(Some(requirements))
|
||||
}))
|
||||
.build()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
.get("sample")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((
|
||||
false,
|
||||
Some(McpServerDisabledReason::Requirements {
|
||||
source: RequirementSource::CloudRequirements,
|
||||
})
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
@@ -6499,6 +6751,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
@@ -7210,6 +7463,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
@@ -18,6 +18,7 @@ use codex_config::FeatureRequirementsToml;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::McpServerIdentity;
|
||||
use codex_config::McpServerRequirement;
|
||||
use codex_config::PluginRequirementsToml;
|
||||
use codex_config::ResidencyRequirement;
|
||||
use codex_config::SandboxModeRequirement;
|
||||
use codex_config::Sourced;
|
||||
@@ -975,8 +976,26 @@ impl Config {
|
||||
) -> McpConfig {
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(self).await;
|
||||
let mut configured_mcp_servers = self.mcp_servers.get().clone();
|
||||
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
|
||||
configured_mcp_servers.entry(name).or_insert(plugin_server);
|
||||
for plugin in loaded_plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
{
|
||||
let mut plugin_mcp_servers = plugin.mcp_servers.clone();
|
||||
filter_plugin_mcp_servers_by_requirements(
|
||||
&plugin.config_name,
|
||||
&mut plugin_mcp_servers,
|
||||
self.config_layer_stack.requirements().plugins.as_ref(),
|
||||
);
|
||||
for (name, plugin_server) in plugin_mcp_servers {
|
||||
configured_mcp_servers.entry(name).or_insert(plugin_server);
|
||||
}
|
||||
}
|
||||
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
|
||||
&& mcp_requirements.value.is_empty()
|
||||
{
|
||||
// A present empty allowlist bans all MCPs, including plugin MCPs merged above.
|
||||
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
|
||||
}
|
||||
|
||||
McpConfig {
|
||||
@@ -1186,6 +1205,35 @@ fn filter_mcp_servers_by_requirements(
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_plugin_mcp_servers_by_requirements(
|
||||
plugin_config_name: &str,
|
||||
mcp_servers: &mut HashMap<String, McpServerConfig>,
|
||||
plugin_requirements: Option<&Sourced<BTreeMap<String, PluginRequirementsToml>>>,
|
||||
) {
|
||||
let Some(requirements) = plugin_requirements else {
|
||||
return;
|
||||
};
|
||||
let source = requirements.source.clone();
|
||||
let plugin_mcp_requirements = requirements
|
||||
.value
|
||||
.get(plugin_config_name)
|
||||
.and_then(|plugin| plugin.mcp_servers.as_ref());
|
||||
|
||||
for (name, server) in mcp_servers.iter_mut() {
|
||||
let allowed = plugin_mcp_requirements
|
||||
.and_then(|mcp_requirements| mcp_requirements.get(name))
|
||||
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
|
||||
if allowed {
|
||||
server.disabled_reason = None;
|
||||
} else {
|
||||
server.enabled = false;
|
||||
server.disabled_reason = Some(McpServerDisabledReason::Requirements {
|
||||
source: source.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn constrain_mcp_servers(
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
mcp_requirements: Option<&Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
@@ -1876,6 +1924,7 @@ impl Config {
|
||||
feature_requirements,
|
||||
managed_hooks: _,
|
||||
mcp_servers,
|
||||
plugins: _,
|
||||
exec_policy: _,
|
||||
enforce_residency,
|
||||
network: network_requirements,
|
||||
|
||||
@@ -141,7 +141,8 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
app_tool_policy.approval
|
||||
} else {
|
||||
custom_mcp_tool_approval_mode(turn_context.as_ref(), &server, &tool_name)
|
||||
custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name)
|
||||
.await
|
||||
};
|
||||
|
||||
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
|
||||
@@ -722,12 +723,13 @@ const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
|
||||
|
||||
fn custom_mcp_tool_approval_mode(
|
||||
async fn custom_mcp_tool_approval_mode(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> AppToolApproval {
|
||||
turn_context
|
||||
let user_configured_mode = turn_context
|
||||
.config
|
||||
.config_layer_stack
|
||||
.effective_config()
|
||||
@@ -739,6 +741,28 @@ fn custom_mcp_tool_approval_mode(
|
||||
})
|
||||
.and_then(|servers| {
|
||||
let server_config = servers.get(server)?;
|
||||
Some(
|
||||
server_config
|
||||
.tools
|
||||
.get(tool_name)
|
||||
.and_then(|tool| tool.approval_mode)
|
||||
.or(server_config.default_tools_approval_mode)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
if let Some(user_configured_mode) = user_configured_mode {
|
||||
return user_configured_mode;
|
||||
}
|
||||
|
||||
sess.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(turn_context.config.as_ref())
|
||||
.await
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.find_map(|plugin| {
|
||||
let server_config = plugin.mcp_servers.get(server)?;
|
||||
server_config
|
||||
.tools
|
||||
.get(tool_name)
|
||||
@@ -1717,7 +1741,7 @@ async fn maybe_persist_mcp_tool_approval(
|
||||
persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name)
|
||||
.await
|
||||
} else {
|
||||
persist_custom_mcp_tool_approval(&turn_context.config, &key.server, &tool_name).await
|
||||
persist_non_app_mcp_tool_approval(sess, &turn_context.config, &key.server, &tool_name).await
|
||||
};
|
||||
|
||||
if let Err(err) = persist_result {
|
||||
@@ -1755,24 +1779,81 @@ async fn persist_codex_app_tool_approval(
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn persist_custom_mcp_tool_approval(
|
||||
config: &Config,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let config_folder = if let Some(project_config_folder) =
|
||||
project_mcp_tool_approval_config_folder(config, server)
|
||||
{
|
||||
project_config_folder
|
||||
} else {
|
||||
let servers = load_global_mcp_servers(&config.codex_home).await?;
|
||||
if !servers.contains_key(server) {
|
||||
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
|
||||
}
|
||||
config.codex_home.clone()
|
||||
let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? else {
|
||||
anyhow::bail!("MCP server `{server}` is not configured in config.toml");
|
||||
};
|
||||
|
||||
ConfigEditsBuilder::new(&config_folder)
|
||||
persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await
|
||||
}
|
||||
|
||||
async fn persist_non_app_mcp_tool_approval(
|
||||
sess: &Session,
|
||||
config: &Config,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? {
|
||||
return persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await;
|
||||
}
|
||||
|
||||
let plugin_config_name = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(config)
|
||||
.await
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.find(|plugin| plugin.mcp_servers.contains_key(server))
|
||||
.map(|plugin| plugin.config_name.clone());
|
||||
|
||||
if let Some(plugin_config_name) = plugin_config_name {
|
||||
return ConfigEditsBuilder::new(&config.codex_home)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"plugins".to_string(),
|
||||
plugin_config_name,
|
||||
"mcp_servers".to_string(),
|
||||
server.to_string(),
|
||||
"tools".to_string(),
|
||||
tool_name.to_string(),
|
||||
"approval_mode".to_string(),
|
||||
],
|
||||
value: value("approve"),
|
||||
}])
|
||||
.apply()
|
||||
.await;
|
||||
}
|
||||
|
||||
anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin")
|
||||
}
|
||||
|
||||
async fn custom_mcp_tool_approval_config_folder(
|
||||
config: &Config,
|
||||
server: &str,
|
||||
) -> anyhow::Result<Option<AbsolutePathBuf>> {
|
||||
if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) {
|
||||
return Ok(Some(project_config_folder));
|
||||
}
|
||||
|
||||
let servers = load_global_mcp_servers(&config.codex_home).await?;
|
||||
Ok(servers
|
||||
.contains_key(server)
|
||||
.then(|| config.codex_home.clone()))
|
||||
}
|
||||
|
||||
async fn persist_custom_mcp_tool_approval_at(
|
||||
config_folder: &AbsolutePathBuf,
|
||||
server: &str,
|
||||
tool_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
ConfigEditsBuilder::new(config_folder)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments: vec![
|
||||
"mcp_servers".to_string(),
|
||||
|
||||
@@ -69,6 +69,30 @@ fn approval_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn write_sample_plugin_mcp(codex_home: &std::path::Path) {
|
||||
let plugin_root = codex_home.join("plugins/cache/test/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "sample"
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin manifest");
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://sample.example/mcp"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write plugin mcp config");
|
||||
}
|
||||
|
||||
fn prompt_options(
|
||||
allow_session_remember: bool,
|
||||
allow_persistent_approval: bool,
|
||||
@@ -1486,23 +1510,116 @@ approval_mode = "prompt"
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "docs", "read"),
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "read").await,
|
||||
AppToolApproval::Approve
|
||||
);
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "docs", "search"),
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "docs", "search").await,
|
||||
AppToolApproval::Prompt
|
||||
);
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "unknown", "search"),
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "unknown", "search").await,
|
||||
AppToolApproval::Auto
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_mcp_tool_approval_mode_uses_plugin_mcp_policy() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
write_sample_plugin_mcp(codex_home.as_path());
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
|
||||
[plugins."sample@test".mcp_servers.sample]
|
||||
default_tools_approval_mode = "prompt"
|
||||
|
||||
[plugins."sample@test".mcp_servers.sample.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
)
|
||||
.expect("seed config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
turn_context.config = Arc::new(config);
|
||||
session.services.plugins_manager.clear_cache();
|
||||
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "read").await,
|
||||
AppToolApproval::Prompt
|
||||
);
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,
|
||||
AppToolApproval::Approve
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_mcp_tool_approval_mode_uses_updated_plugin_mcp_policy_after_cache_warm() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
write_sample_plugin_mcp(codex_home.as_path());
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("seed config");
|
||||
let initial_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load initial config");
|
||||
session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&initial_config)
|
||||
.await;
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
|
||||
[plugins."sample@test".mcp_servers.sample.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#,
|
||||
)
|
||||
.expect("update config");
|
||||
let updated_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load updated config");
|
||||
turn_context.config = Arc::new(updated_config);
|
||||
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&session, &turn_context, "sample", "search").await,
|
||||
AppToolApproval::Approve
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -1584,6 +1701,56 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve
|
||||
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_writes_plugin_mcp_policy() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
write_sample_plugin_mcp(codex_home.as_path());
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("seed config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
turn_context.config = Arc::new(config);
|
||||
session.services.plugins_manager.clear_cache();
|
||||
let key = McpToolApprovalKey {
|
||||
server: "sample".to_string(),
|
||||
connector_id: None,
|
||||
tool_name: "search".to_string(),
|
||||
};
|
||||
|
||||
maybe_persist_mcp_tool_approval(&session, &turn_context, key.clone()).await;
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let parsed: ConfigToml = toml::from_str(&contents).expect("parse config");
|
||||
let tool = parsed
|
||||
.plugins
|
||||
.get("sample@test")
|
||||
.and_then(|plugin| plugin.mcp_servers.get("sample"))
|
||||
.and_then(|server| server.tools.get("search"))
|
||||
.expect("sample/search tool config exists");
|
||||
|
||||
assert_eq!(
|
||||
tool,
|
||||
&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
}
|
||||
);
|
||||
assert!(contents.contains(r#"[plugins."sample@test".mcp_servers.sample.tools.search]"#));
|
||||
assert_eq!(mcp_tool_approval_is_remembered(&session, &key).await, true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_server() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::config::edit::ConfigEditsBuilder;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::version_for_toml;
|
||||
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
use codex_core_plugins::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
@@ -359,7 +360,7 @@ pub struct PluginsManager {
|
||||
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
|
||||
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
|
||||
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
|
||||
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
|
||||
cached_enabled_outcome: RwLock<Option<CachedPluginLoadOutcome>>,
|
||||
// TODO(remote plugins): reset this cache when ChatGPT auth/account state changes so stale
|
||||
// remote installed state cannot remain effective for a different account.
|
||||
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
||||
@@ -369,6 +370,12 @@ pub struct PluginsManager {
|
||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedPluginLoadOutcome {
|
||||
config_version: String,
|
||||
outcome: PluginLoadOutcome,
|
||||
}
|
||||
|
||||
impl PluginsManager {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self::new_with_restriction_product(codex_home, Some(Product::Codex))
|
||||
@@ -436,7 +443,9 @@ impl PluginsManager {
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome() {
|
||||
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
|
||||
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
@@ -452,7 +461,10 @@ impl PluginsManager {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
*cache = Some(outcome.clone());
|
||||
*cache = Some(CachedPluginLoadOutcome {
|
||||
config_version,
|
||||
outcome: outcome.clone(),
|
||||
});
|
||||
outcome
|
||||
}
|
||||
|
||||
@@ -492,10 +504,17 @@ impl PluginsManager {
|
||||
.effective_skill_roots()
|
||||
}
|
||||
|
||||
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
|
||||
fn cached_enabled_outcome(&self, config_version: &str) -> Option<PluginLoadOutcome> {
|
||||
match self.cached_enabled_outcome.read() {
|
||||
Ok(cache) => cache.clone(),
|
||||
Err(err) => err.into_inner().clone(),
|
||||
Ok(cache) => cache
|
||||
.as_ref()
|
||||
.filter(|cached| cached.config_version == config_version)
|
||||
.map(|cached| cached.outcome.clone()),
|
||||
Err(err) => err
|
||||
.into_inner()
|
||||
.as_ref()
|
||||
.filter(|cached| cached.config_version == config_version)
|
||||
.map(|cached| cached.outcome.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,13 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::AppToolApproval;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
|
||||
use codex_core_plugins::loader::load_plugins_from_layer_stack;
|
||||
@@ -247,6 +249,74 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_applies_plugin_mcp_server_policy() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join("test/sample/local");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "sample"
|
||||
}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample": {
|
||||
"type": "http",
|
||||
"url": "https://sample.example/mcp",
|
||||
"default_tools_approval_mode": "prompt",
|
||||
"enabled_tools": ["read", "search"],
|
||||
"tools": {
|
||||
"search": { "approval_mode": "prompt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let config_toml = r#"
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
|
||||
[plugins."sample@test".mcp_servers.sample]
|
||||
enabled = false
|
||||
default_tools_approval_mode = "approve"
|
||||
enabled_tools = ["search"]
|
||||
disabled_tools = ["delete"]
|
||||
|
||||
[plugins."sample@test".mcp_servers.sample.tools.search]
|
||||
approval_mode = "approve"
|
||||
"#;
|
||||
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
|
||||
let server = outcome.plugins()[0]
|
||||
.mcp_servers
|
||||
.get("sample")
|
||||
.expect("sample server");
|
||||
|
||||
assert!(!server.enabled);
|
||||
assert_eq!(
|
||||
server.default_tools_approval_mode,
|
||||
Some(AppToolApproval::Approve)
|
||||
);
|
||||
assert_eq!(server.enabled_tools, Some(vec!["search".to_string()]));
|
||||
assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()]));
|
||||
assert_eq!(
|
||||
server.tools.get("search"),
|
||||
Some(&McpServerToolConfig {
|
||||
approval_mode: Some(AppToolApproval::Approve),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
@@ -697,6 +697,7 @@ mod tests {
|
||||
},
|
||||
},
|
||||
)])),
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: Some(ResidencyRequirement::Us),
|
||||
@@ -896,6 +897,7 @@ approval_policy = "never"
|
||||
feature_requirements: None,
|
||||
hooks: None,
|
||||
mcp_servers: None,
|
||||
plugins: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
|
||||
Reference in New Issue
Block a user