Move plugin manager out of core

This commit is contained in:
xli-oai
2026-04-30 01:48:24 -07:00
parent c37f7434ba
commit 44ac67a88c
35 changed files with 864 additions and 1413 deletions
+5 -1
View File
@@ -13,6 +13,8 @@ path = "src/lib.rs"
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-analytics = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-core-skills = { workspace = true }
@@ -23,6 +25,7 @@ codex-model-provider = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-plugins = { workspace = true }
chrono = { workspace = true }
@@ -41,8 +44,9 @@ url = { workspace = true }
zip = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
libc = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
tracing-test = { workspace = true }
wiremock = { workspace = true }
+105
View File
@@ -0,0 +1,105 @@
use anyhow::Context;
use std::collections::HashSet;
use std::path::Path;
use tracing::warn;
use crate::OPENAI_BUNDLED_MARKETPLACE_NAME;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
use crate::manager::PluginsManager;
use codex_config::ConfigLayerStack;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_plugin::PluginCapabilitySummary;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_BUNDLED_MARKETPLACE_NAME,
OPENAI_CURATED_MARKETPLACE_NAME,
];
pub async fn list_tool_suggest_discoverable_plugins(
codex_home: &Path,
config_layer_stack: &ConfigLayerStack,
plugins_enabled: bool,
tool_suggest: &ToolSuggestConfig,
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !plugins_enabled {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(codex_home.to_path_buf());
let configured_plugin_ids = tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin)
.map(|discoverable| discoverable.id.as_str())
.collect::<HashSet<_>>();
let disabled_plugin_ids = tool_suggest
.disabled_tools
.iter()
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Plugin)
.map(|disabled_tool| disabled_tool.id.as_str())
.collect::<HashSet<_>>();
let marketplaces = plugins_manager
.list_marketplaces_for_config(config_layer_stack, plugins_enabled, &[])
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
for marketplace in marketplaces {
let marketplace_name = marketplace.name;
if !TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST.contains(&marketplace_name.as_str()) {
continue;
}
for plugin in marketplace.plugins {
if plugin.installed
|| disabled_plugin_ids.contains(plugin.id.as_str())
|| (!TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
&& !configured_plugin_ids.contains(plugin.id.as_str()))
{
continue;
}
let plugin_id = plugin.id.clone();
match plugins_manager
.read_plugin_detail_for_marketplace_plugin(
config_layer_stack,
&marketplace_name,
plugin,
)
.await
{
Ok(plugin) => {
let plugin: PluginCapabilitySummary = plugin.into();
discoverable_plugins.push(DiscoverablePluginInfo {
id: plugin.config_name,
name: plugin.display_name,
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names,
app_connector_ids: plugin
.app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
});
}
Err(err) => {
warn!("failed to load discoverable plugin suggestion {plugin_id}: {err:#}")
}
}
}
}
discoverable_plugins.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;
@@ -0,0 +1,553 @@
use super::*;
use crate::manager::PluginInstallRequest;
use crate::startup_sync::curated_plugins_repo_path;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::config_toml::ConfigToml;
use codex_config::types::ToolSuggestConfig;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
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"]);
write_plugins_feature_config(codex_home.path());
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_microsoft_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["teams", "sharepoint", "outlook-email", "outlook-calendar"],
);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.await
.unwrap();
assert_eq!(
discoverable_plugins
.into_iter()
.map(|plugin| plugin.id)
.collect::<Vec<_>>(),
vec![
"outlook-calendar@openai-curated".to_string(),
"outlook-email@openai-curated".to_string(),
"sharepoint@openai-curated".to_string(),
"teams@openai-curated".to_string(),
]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configured_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let plugin_id = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
.iter()
.copied()
.find(|plugin_id| {
plugin_id
.rsplit_once('@')
.is_some_and(|(_plugin_name, marketplace_name)| {
marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
})
})
.expect("allowlist should include a bundled plugin");
let (plugin_name, marketplace_name) = plugin_id
.rsplit_once('@')
.expect("plugin id should include a marketplace");
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "{plugin_name}", "source": {{"source": "local", "path": "./plugins/{plugin_name}"}}}}
]
}}
"#
),
);
write_curated_plugin(&marketplace_root, plugin_name);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
[tool_suggest]
discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
"#
),
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, plugin_id);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let marketplace_name = TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST
.iter()
.copied()
.filter_map(|plugin_id| plugin_id.rsplit_once('@'))
.find(|(_plugin_name, marketplace_name)| {
*marketplace_name == OPENAI_BUNDLED_MARKETPLACE_NAME
})
.map(|(_plugin_name, marketplace_name)| marketplace_name)
.expect("allowlist should include a bundled plugin");
let marketplace_root = codex_home
.path()
.join(format!(".tmp/marketplaces/{marketplace_name}"));
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{marketplace_name}",
"plugins": [
{{"name": "sample", "source": {{"source": "local", "path": "./plugins/sample"}}}}
]
}}
"#
),
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
),
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ false,
)
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
crate::manager::PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."slack@openai-curated"]
enabled = true
"#,
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[tool_suggest]
disabled_tools = [
{ type = "plugin", id = "slack@openai-curated" }
]
"#,
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids() {
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"]);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[tool_suggest]
discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
"#,
);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "sample@openai-curated".to_string(),
name: "sample".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(
&curated_root,
&["slack", "build-ios-apps", "life-science-research"],
);
write_plugins_feature_config(codex_home.path());
let too_long_prompt = "x".repeat(129);
for plugin_name in ["build-ios-apps", "life-science-research"] {
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",
"interface": {{
"defaultPrompt": "{too_long_prompt}"
}}
}}"#
),
);
}
let buffer: &'static std::sync::Mutex<Vec<u8>> =
Box::leak(Box::new(std::sync::Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_level(true)
.with_ansi(false)
.with_max_level(Level::WARN)
.with_span_events(FmtSpan::NONE)
.with_writer(MockWriter::new(buffer))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
codex_home.path(),
/*plugins_enabled*/ true,
)
.await
.unwrap();
assert_eq!(discoverable_plugins.len(), 1);
assert_eq!(discoverable_plugins[0].id, "slack@openai-curated");
let logs = String::from_utf8(buffer.lock().expect("buffer lock").clone())
.expect("utf8 logs")
.replace('\\', "/");
assert_eq!(logs.matches("ignoring interface.defaultPrompt").count(), 2);
let normalized_logs = logs.replace('\\', "/");
assert_eq!(
normalized_logs
.matches("build-ios-apps/.codex-plugin/plugin.json")
.count(),
1
);
assert_eq!(
normalized_logs
.matches("life-science-research/.codex-plugin/plugin.json")
.count(),
1
);
}
async fn list_tool_suggest_discoverable_plugins_for_test(
codex_home: &Path,
plugins_enabled: bool,
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
let config_layer_stack = config_layer_stack(codex_home);
let tool_suggest = tool_suggest_config(codex_home);
list_tool_suggest_discoverable_plugins(
codex_home,
&config_layer_stack,
plugins_enabled,
&tool_suggest,
)
.await
}
fn config_layer_stack(codex_home: &Path) -> ConfigLayerStack {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let raw = fs::read_to_string(&config_path).unwrap_or_default();
let config = if raw.is_empty() {
toml::Value::Table(toml::map::Map::new())
} else {
toml::from_str(&raw).expect("test config should parse")
};
ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: AbsolutePathBuf::try_from(config_path).expect("absolute config path"),
},
config,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack should load")
}
fn tool_suggest_config(codex_home: &Path) -> ToolSuggestConfig {
let raw = fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).unwrap_or_default();
if raw.is_empty() {
return ToolSuggestConfig::default();
}
toml::from_str::<ConfigToml>(&raw)
.expect("test config should parse")
.tool_suggest
.unwrap_or_default()
}
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_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
);
for plugin_name in plugin_names {
write_curated_plugin(root, plugin_name);
}
}
fn write_curated_plugin_sha(codex_home: &Path) {
write_file(
&codex_home.join(".tmp/plugins.sha"),
"0123456789abcdef0123456789abcdef01234567\n",
);
}
fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
+7
View File
@@ -1,5 +1,7 @@
pub mod discoverable;
pub mod installed_marketplaces;
pub mod loader;
pub mod manager;
pub mod manifest;
pub mod marketplace;
pub mod marketplace_add;
@@ -12,6 +14,8 @@ pub mod startup_sync;
pub mod store;
pub mod toggles;
use codex_config::types::McpServerConfig;
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled";
@@ -30,3 +34,6 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"figma@openai-curated",
"computer-use@openai-bundled",
];
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
File diff suppressed because it is too large Load Diff