Files
codex/codex-rs/core/src/plugins/manager.rs
T
xl-openai 9b004e2db1 Refactor plugin config and cache path (#13333)
Update config.toml plugin entries to use
<plugin_name>@<marketplace_name> as the key.
Plugin now stays in
[plugins/cache/marketplace-name/plugin-name/$version/]
Clean up the plugin code structure.
Add plugin install functionality (not used yet).
2026-03-03 15:00:18 -08:00

747 lines
24 KiB
Rust

use super::load_plugin_manifest;
use super::plugin_manifest_name;
use super::store::DEFAULT_PLUGIN_VERSION;
use super::store::PluginId;
use super::store::PluginInstallRequest;
use super::store::PluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
use crate::config::Config;
use crate::config::ConfigService;
use crate::config::ConfigServiceError;
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use crate::config::types::McpServerConfig;
use crate::config::types::PluginConfig;
use crate::config_loader::ConfigLayerStack;
use crate::features::Feature;
use crate::features::FeatureOverrides;
use crate::features::Features;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::MergeStrategy;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::RwLock;
use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
pub manifest_name: Option<String>,
pub root: AbsolutePathBuf,
pub enabled: bool,
pub skill_roots: Vec<PathBuf>,
pub mcp_servers: HashMap<String, McpServerConfig>,
pub error: Option<String>,
}
impl LoadedPlugin {
fn is_active(&self) -> bool {
self.enabled && self.error.is_none()
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PluginLoadOutcome {
pub plugins: Vec<LoadedPlugin>,
}
impl PluginLoadOutcome {
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
let mut skill_roots: Vec<PathBuf> = self
.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
.collect();
skill_roots.sort_unstable();
skill_roots.dedup();
skill_roots
}
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
let mut mcp_servers = HashMap::new();
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
for (name, config) in &plugin.mcp_servers {
mcp_servers
.entry(name.clone())
.or_insert_with(|| config.clone());
}
}
mcp_servers
}
}
pub struct PluginsManager {
codex_home: PathBuf,
store: PluginStore,
cache_by_cwd: RwLock<HashMap<PathBuf, PluginLoadOutcome>>,
}
impl PluginsManager {
pub fn new(codex_home: PathBuf) -> Self {
Self {
codex_home: codex_home.clone(),
store: PluginStore::new(codex_home),
cache_by_cwd: RwLock::new(HashMap::new()),
}
}
pub fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome {
self.plugins_for_layer_stack(&config.cwd, &config.config_layer_stack, false)
}
pub fn plugins_for_layer_stack(
&self,
cwd: &Path,
config_layer_stack: &ConfigLayerStack,
force_reload: bool,
) -> PluginLoadOutcome {
if !plugins_feature_enabled_from_stack(config_layer_stack) {
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.insert(cwd.to_path_buf(), PluginLoadOutcome::default());
return PluginLoadOutcome::default();
}
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
return outcome;
}
let outcome = load_plugins_from_layer_stack(config_layer_stack, &self.store);
log_plugin_load_errors(&outcome);
let mut cache = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache.insert(cwd.to_path_buf(), outcome.clone());
outcome
}
pub fn clear_cache(&self) {
let mut cache_by_cwd = match self.cache_by_cwd.write() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
cache_by_cwd.clear();
}
fn cached_outcome_for_cwd(&self, cwd: &Path) -> Option<PluginLoadOutcome> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
}
}
pub async fn install_plugin(
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || store.install(request))
.await
.map_err(PluginInstallError::join)??;
ConfigService::new_with_defaults(self.codex_home.clone())
.write_value(ConfigValueWriteParams {
key_path: format!("plugins.{}", result.plugin_id.as_key()),
value: json!({
"enabled": true,
}),
merge_strategy: MergeStrategy::Replace,
file_path: None,
expected_version: None,
})
.await
.map(|_| ())
.map_err(PluginInstallError::from)?;
Ok(result)
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginInstallError {
#[error("{0}")]
Store(#[from] PluginStoreError),
#[error("{0}")]
Config(#[from] ConfigServiceError),
#[error("failed to join plugin install task: {0}")]
Join(#[from] tokio::task::JoinError),
}
impl PluginInstallError {
fn join(source: tokio::task::JoinError) -> Self {
Self::Join(source)
}
}
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
let effective_config = config_layer_stack.effective_config();
let Ok(config_toml) = effective_config.try_into::<ConfigToml>() else {
warn!("failed to deserialize config when checking plugin feature flag");
return false;
};
let config_profile = config_toml
.get_config_profile(config_toml.profile.clone())
.unwrap_or_else(|_| ConfigProfile::default());
let features =
Features::from_config(&config_toml, &config_profile, FeatureOverrides::default());
features.enabled(Feature::Plugins)
}
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
for plugin in outcome
.plugins
.iter()
.filter(|plugin| plugin.error.is_some())
{
if let Some(error) = plugin.error.as_deref() {
warn!(
plugin = plugin.config_name,
path = %plugin.root.display(),
"failed to load plugin: {error}"
);
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginMcpFile {
#[serde(default)]
mcp_servers: HashMap<String, JsonValue>,
}
pub(crate) fn load_plugins_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
store: &PluginStore,
) -> PluginLoadOutcome {
let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack)
.into_iter()
.collect();
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
let mut plugins = Vec::with_capacity(configured_plugins.len());
let mut seen_mcp_server_names = HashMap::<String, String>::new();
for (configured_name, plugin) in configured_plugins {
let loaded_plugin = load_plugin(configured_name.clone(), &plugin, store);
for name in loaded_plugin.mcp_servers.keys() {
if let Some(previous_plugin) =
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
{
warn!(
plugin = configured_name,
previous_plugin,
server = name,
"skipping duplicate plugin MCP server name"
);
}
}
plugins.push(loaded_plugin);
}
PluginLoadOutcome { plugins }
}
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
for ancestor in path.ancestors() {
if let Some(manifest) = load_plugin_manifest(ancestor) {
return Some(plugin_manifest_name(&manifest, ancestor));
}
}
None
}
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let effective_config = config_layer_stack.effective_config();
let Some(plugins_value) = effective_config.get("plugins") else {
return HashMap::new();
};
match plugins_value.clone().try_into() {
Ok(plugins) => plugins,
Err(err) => {
warn!("invalid plugins config: {err}");
HashMap::new()
}
}
}
fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin {
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
let plugin_root = PluginId::parse(&config_name)
.map(|plugin_id| store.plugin_root(&plugin_id, &plugin_version));
let root = match &plugin_root {
Ok(plugin_root) => plugin_root.clone(),
Err(_) => store.root().clone(),
};
let mut loaded_plugin = LoadedPlugin {
config_name,
manifest_name: None,
root,
enabled: plugin.enabled,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
error: None,
};
if !plugin.enabled {
return loaded_plugin;
}
let plugin_root = match plugin_root {
Ok(plugin_root) => plugin_root,
Err(err) => {
loaded_plugin.error = Some(err.to_string());
return loaded_plugin;
}
};
if !plugin_root.as_path().is_dir() {
loaded_plugin.error = Some("path does not exist or is not a directory".to_string());
return loaded_plugin;
}
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
loaded_plugin.error = Some("missing or invalid .codex-plugin/plugin.json".to_string());
return loaded_plugin;
};
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path());
let mut mcp_servers = HashMap::new();
for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) {
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path);
for (name, config) in plugin_mcp.mcp_servers {
if mcp_servers.insert(name.clone(), config).is_some() {
warn!(
plugin = %plugin_root.display(),
path = %mcp_config_path.display(),
server = name,
"plugin MCP file overwrote an earlier server definition"
);
}
}
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin
}
fn default_skill_roots(plugin_root: &Path) -> Vec<PathBuf> {
let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME);
if skills_dir.is_dir() {
vec![skills_dir]
} else {
Vec::new()
}
}
fn default_mcp_config_paths(plugin_root: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let default_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
if default_path.is_file() {
paths.push(default_path);
}
paths.sort_unstable();
paths.dedup();
paths
}
fn load_mcp_servers_from_file(plugin_root: &Path, mcp_config_path: &Path) -> PluginMcpDiscovery {
let Ok(contents) = fs::read_to_string(mcp_config_path) else {
return PluginMcpDiscovery::default();
};
let parsed = match serde_json::from_str::<PluginMcpFile>(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warn!(
path = %mcp_config_path.display(),
"failed to parse plugin MCP config: {err}"
);
return PluginMcpDiscovery::default();
}
};
normalize_plugin_mcp_servers(
plugin_root,
parsed.mcp_servers,
mcp_config_path.to_string_lossy().as_ref(),
)
}
fn normalize_plugin_mcp_servers(
plugin_root: &Path,
plugin_mcp_servers: HashMap<String, JsonValue>,
source: &str,
) -> PluginMcpDiscovery {
let mut mcp_servers = HashMap::new();
for (name, config_value) in plugin_mcp_servers {
let normalized = normalize_plugin_mcp_server_value(plugin_root, config_value);
match serde_json::from_value::<McpServerConfig>(JsonValue::Object(normalized)) {
Ok(config) => {
mcp_servers.insert(name, config);
}
Err(err) => {
warn!(
plugin = %plugin_root.display(),
server = name,
"failed to parse plugin MCP server from {source}: {err}"
);
}
}
}
PluginMcpDiscovery { mcp_servers }
}
fn normalize_plugin_mcp_server_value(
plugin_root: &Path,
value: JsonValue,
) -> JsonMap<String, JsonValue> {
let mut object = match value {
JsonValue::Object(object) => object,
_ => return JsonMap::new(),
};
if let Some(JsonValue::String(transport_type)) = object.remove("type") {
match transport_type.as_str() {
"http" | "streamable_http" | "streamable-http" => {}
"stdio" => {}
other => {
warn!(
plugin = %plugin_root.display(),
transport = other,
"plugin MCP server uses an unknown transport type"
);
}
}
}
if let Some(JsonValue::Object(oauth)) = object.remove("oauth")
&& oauth.contains_key("callbackPort")
{
warn!(
plugin = %plugin_root.display(),
"plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings"
);
}
if let Some(JsonValue::String(cwd)) = object.get("cwd")
&& !Path::new(cwd).is_absolute()
{
object.insert(
"cwd".to_string(),
JsonValue::String(plugin_root.join(cwd).display().to_string()),
);
}
object
}
#[derive(Debug, Default)]
struct PluginMcpDiscovery {
mcp_servers: HashMap<String, McpServerConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::types::McpServerTransportConfig;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
use toml::Value;
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"}}"#),
)
.unwrap();
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert(
"plugins".to_string(),
Value::Boolean(plugins_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert("enabled".to_string(), Value::Boolean(enabled));
let mut plugins = toml::map::Map::new();
plugins.insert("sample@test".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
}
async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome {
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let config = ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.build()
.await
.expect("config should load");
PluginsManager::new(codex_home.to_path_buf()).plugins_for_config(&config)
}
#[tokio::test]
async fn load_plugins_loads_default_skills_and_mcp_servers() {
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("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp",
"oauth": {
"clientId": "client-id",
"callbackPort": 3118
}
}
}
}"#,
);
let outcome =
load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()).await;
assert_eq!(
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: Some("sample".to_string()),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills")],
mcp_servers: HashMap::from([(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]),
error: None,
}]
);
assert_eq!(
outcome.effective_skill_roots(),
vec![plugin_root.join("skills")]
);
assert_eq!(outcome.effective_mcp_servers().len(), 1);
}
#[tokio::test]
async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
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"
}
}
}"#,
);
let outcome =
load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()).await;
assert_eq!(
outcome.plugins,
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
manifest_name: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
mcp_servers: HashMap::new(),
error: None,
}]
);
assert!(outcome.effective_skill_roots().is_empty());
assert!(outcome.effective_mcp_servers().is_empty());
}
#[test]
fn plugin_namespace_for_skill_path_uses_manifest_name() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home.path().join("plugins/sample");
let skill_path = plugin_root.join("skills/search/SKILL.md");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(&skill_path, "---\ndescription: search\n---\n");
assert_eq!(
plugin_namespace_for_skill_path(&skill_path),
Some("sample".to_string())
);
}
#[tokio::test]
async fn load_plugins_returns_empty_when_feature_disabled() {
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("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome =
load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path()).await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() {
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"}"#,
);
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert("enabled".to_string(), Value::Boolean(true));
let mut plugins = toml::map::Map::new();
plugins.insert("sample".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
let outcome = load_plugins_from_config(
&toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"),
codex_home.path(),
)
.await;
assert_eq!(outcome.plugins.len(), 1);
assert_eq!(
outcome.plugins[0].error.as_deref(),
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
);
assert!(outcome.effective_skill_roots().is_empty());
assert!(outcome.effective_mcp_servers().is_empty());
}
#[tokio::test]
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
source_path: tmp.path().join("sample-plugin"),
marketplace_name: None,
})
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path,
}
);
let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap();
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
assert!(config.contains("enabled = true"));
}
}