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
+9 -1
View File
@@ -5,6 +5,7 @@ use crate::config::ConfigBuilder;
use crate::plugins::PluginsManager;
use crate::skills_load_input_from_config;
use codex_config::ConfigLayerStackOrdering;
use codex_features::Feature;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
@@ -655,7 +656,14 @@ enabled = false
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager =
SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true);
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
let plugin_outcome = plugins_manager
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let outcome = skills_manager
+19
View File
@@ -1201,6 +1201,25 @@ impl ConfigEditsBuilder {
self
}
pub fn set_plugin_enabled(mut self, plugin_id: &str, enabled: bool) -> Self {
self.edits.push(ConfigEdit::SetPath {
segments: vec![
"plugins".to_string(),
plugin_id.to_string(),
"enabled".to_string(),
],
value: value(enabled),
});
self
}
pub fn clear_plugin(mut self, plugin_id: &str) -> Self {
self.edits.push(ConfigEdit::ClearPath {
segments: vec!["plugins".to_string(), plugin_id.to_string()],
});
self
}
/// Enable or disable a feature flag by key under the `[features]` table.
///
/// Disabling a default-false feature clears the root-scoped key instead of
+33
View File
@@ -1313,6 +1313,39 @@ model_reasoning_effort = "high"
assert_eq!(contents, initial_expected);
}
#[test]
fn blocking_builder_sets_and_clears_plugin_config() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.set_plugin_enabled("sample@test", /*enabled*/ true)
.set_plugin_enabled("other@test", /*enabled*/ false)
.apply_blocking()
.expect("persist plugin config");
ConfigEditsBuilder::new(codex_home)
.clear_plugin("sample@test")
.apply_blocking()
.expect("clear plugin config");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let config: TomlValue = toml::from_str(&raw).expect("parse config");
let plugins = config
.get("plugins")
.and_then(TomlValue::as_table)
.expect("plugins table should exist");
assert!(!plugins.contains_key("sample@test"));
assert_eq!(
plugins
.get("other@test")
.and_then(TomlValue::as_table)
.and_then(|plugin| plugin.get("enabled"))
.and_then(TomlValue::as_bool),
Some(false)
);
}
#[tokio::test]
async fn blocking_set_asynchronous_helpers_available() {
let tmp = tempdir().expect("tmpdir");
+8 -1
View File
@@ -1000,7 +1000,14 @@ impl Config {
&self,
plugins_manager: &crate::plugins::PluginsManager,
) -> McpConfig {
let loaded_plugins = plugins_manager.plugins_for_config(self).await;
let loaded_plugins = plugins_manager
.plugins_for_config(
&self.config_layer_stack,
self.features.enabled(Feature::Plugins),
self.features.enabled(Feature::RemotePlugin),
self.features.enabled(Feature::PluginHooks),
)
.await;
let mut configured_mcp_servers = self.mcp_servers.get().clone();
for plugin in loaded_plugins
.plugins()
+16 -6
View File
@@ -14,6 +14,7 @@ pub use codex_app_server_protocol::AppInfo;
pub use codex_app_server_protocol::AppMetadata;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
use codex_core_plugins::discoverable::list_tool_suggest_discoverable_plugins;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
@@ -27,7 +28,6 @@ use tracing::warn;
use crate::config::Config;
use crate::mcp::McpManager;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::session::INITIAL_SUBMIT_ID;
use codex_config::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
@@ -131,10 +131,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
.await?
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
config.codex_home.as_path(),
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
&config.tool_suggest,
)
.await?
.into_iter()
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
@@ -404,7 +409,12 @@ fn write_cached_accessible_connectors(
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(config)
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await
.capability_summaries()
.iter()
+2
View File
@@ -68,6 +68,8 @@ pub use message_history::lookup as lookup_message_history_entry;
pub use utils::path_utils;
pub mod personality_migration;
pub mod plugins;
#[cfg(test)]
mod plugins_manager_tests;
#[doc(hidden)]
pub(crate) mod prompt_debug;
#[doc(hidden)]
+12 -2
View File
@@ -756,7 +756,12 @@ async fn custom_mcp_tool_approval_mode(
sess.services
.plugins_manager
.plugins_for_config(turn_context.config.as_ref())
.plugins_for_config(
&turn_context.config.config_layer_stack,
turn_context.config.features.enabled(Feature::Plugins),
turn_context.config.features.enabled(Feature::RemotePlugin),
turn_context.config.features.enabled(Feature::PluginHooks),
)
.await
.plugins()
.iter()
@@ -1805,7 +1810,12 @@ async fn persist_non_app_mcp_tool_approval(
let plugin_config_name = sess
.services
.plugins_manager
.plugins_for_config(config)
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await
.plugins()
.iter()
+7 -1
View File
@@ -13,6 +13,7 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_features::Feature;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_model_provider::create_model_provider;
@@ -1594,7 +1595,12 @@ enabled = true
session
.services
.plugins_manager
.plugins_for_config(&initial_config)
.plugins_for_config(
&initial_config.config_layer_stack,
initial_config.features.enabled(Feature::Plugins),
initial_config.features.enabled(Feature::RemotePlugin),
initial_config.features.enabled(Feature::PluginHooks),
)
.await;
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
-99
View File
@@ -1,99 +0,0 @@
use anyhow::Context;
use std::collections::HashSet;
use tracing::warn;
use super::PluginCapabilitySummary;
use super::PluginsManager;
use crate::config::Config;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST;
use codex_features::Feature;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_BUNDLED_MARKETPLACE_NAME,
OPENAI_CURATED_MARKETPLACE_NAME,
];
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
let configured_plugin_ids = config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Plugin)
.map(|discoverable| discoverable.id.as_str())
.collect::<HashSet<_>>();
let disabled_plugin_ids = config
.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, &[])
.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, &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;
@@ -1,384 +0,0 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
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 config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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(crate::config::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 config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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(crate::config::CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.{marketplace_name}]
source_type = "git"
source = "/tmp/{marketplace_name}"
"#
),
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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 config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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());
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");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.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(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = true
[tool_suggest]
disabled_tools = [
{ type = "plugin", id = "slack@openai-curated" }
]
"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = true
[tool_suggest]
discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.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 config = load_plugins_config(codex_home.path()).await;
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(&config)
.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
);
}
File diff suppressed because it is too large Load Diff
+14 -23
View File
@@ -1,14 +1,23 @@
use codex_config::types::McpServerConfig;
mod discoverable;
mod injection;
mod manager;
mod mentions;
mod render;
mod startup_sync;
#[cfg(test)]
pub(crate) mod test_support;
pub use codex_core_plugins::LoadedPlugin;
pub use codex_core_plugins::PluginLoadOutcome;
pub use codex_core_plugins::manager::ConfiguredMarketplace;
pub use codex_core_plugins::manager::ConfiguredMarketplaceListOutcome;
pub use codex_core_plugins::manager::ConfiguredMarketplacePlugin;
pub use codex_core_plugins::manager::PluginDetail;
pub use codex_core_plugins::manager::PluginDetailsUnavailableReason;
pub use codex_core_plugins::manager::PluginInstallError;
pub use codex_core_plugins::manager::PluginInstallOutcome;
pub use codex_core_plugins::manager::PluginInstallRequest;
pub use codex_core_plugins::manager::PluginReadOutcome;
pub use codex_core_plugins::manager::PluginReadRequest;
pub use codex_core_plugins::manager::PluginUninstallError;
pub use codex_core_plugins::manager::PluginsManager;
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
pub use codex_plugin::AppConnectorId;
@@ -19,25 +28,7 @@ pub use codex_plugin::PluginIdError;
pub use codex_plugin::PluginTelemetryMetadata;
pub use codex_plugin::validate_plugin_segment;
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::ConfiguredMarketplace;
pub use manager::ConfiguredMarketplaceListOutcome;
pub use manager::ConfiguredMarketplacePlugin;
pub use manager::PluginDetail;
pub use manager::PluginDetailsUnavailableReason;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginRemoteSyncError;
pub use manager::PluginUninstallError;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use mentions::build_connector_slug_counts;
-100
View File
@@ -1,100 +0,0 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::config::Config;
use crate::plugins::PluginsManager;
use codex_core_plugins::startup_sync::has_local_curated_plugins_snapshot;
use codex_login::AuthManager;
use tracing::info;
use tracing::warn;
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
config: Config,
auth_manager: Arc<AuthManager>,
) {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path());
if marker_path.is_file() {
return;
}
tokio::spawn(async move {
if marker_path.is_file() {
return;
}
if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await {
warn!(
codex_home = %codex_home.display(),
"skipping startup remote plugin sync because curated marketplace is not ready"
);
return;
}
let auth = auth_manager.auth().await;
match manager
.sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true)
.await
{
Ok(sync_result) => {
info!(
installed_plugin_ids = ?sync_result.installed_plugin_ids,
enabled_plugin_ids = ?sync_result.enabled_plugin_ids,
disabled_plugin_ids = ?sync_result.disabled_plugin_ids,
uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids,
"completed startup remote plugin sync"
);
if let Err(err) =
write_startup_remote_plugin_sync_marker(codex_home.as_path()).await
{
warn!(
error = %err,
path = %marker_path.display(),
"failed to persist startup remote plugin sync marker"
);
}
}
Err(err) => {
warn!(
error = %err,
"startup remote plugin sync failed; will retry on next app-server start"
);
}
}
});
}
fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf {
codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE)
}
async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool {
let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT;
loop {
if has_local_curated_plugins_snapshot(codex_home) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home);
if let Some(parent) = marker_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(marker_path, b"ok\n").await
}
#[cfg(test)]
#[path = "startup_sync_tests.rs"]
mod tests;
@@ -1,90 +0,0 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::time::Duration;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = crate::plugins::test_support::load_plugins_config(tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
start_startup_remote_plugin_sync_once(
Arc::clone(&manager),
tmp.path().to_path_buf(),
config,
auth_manager,
);
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if marker_path.is_file() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("marker should be written");
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
let config =
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist");
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
}
@@ -1,4 +1,3 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::plugins::LoadedPlugin;
@@ -8,6 +7,7 @@ use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::*;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::AppToolApproval;
use codex_config::ConfigLayerEntry;
@@ -17,16 +17,31 @@ use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::loader::configured_curated_plugin_ids_from_codex_home;
use codex_core_plugins::loader::load_plugins_from_layer_stack;
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
use codex_core_plugins::loader::refresh_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::MarketplacePluginPolicy;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::remote_legacy::RemotePluginFetchError;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_core_plugins::store::PluginStore;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
@@ -136,7 +151,7 @@ async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> Plugi
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let config = load_config(codex_home, codex_home).await;
PluginsManager::new(codex_home.to_path_buf())
.plugins_for_config(&config)
.plugins_for_test_config(&config)
.await
}
@@ -149,6 +164,83 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config {
.expect("config should load")
}
#[allow(async_fn_in_trait)]
trait PluginsManagerTestConfigExt {
async fn plugins_for_test_config(&self, config: &crate::config::Config) -> PluginLoadOutcome;
fn list_marketplaces_for_test_config(
&self,
config: &crate::config::Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError>;
async fn read_plugin_for_test_config(
&self,
config: &crate::config::Config,
request: &PluginReadRequest,
) -> Result<PluginReadOutcome, MarketplaceError>;
async fn featured_plugin_ids_for_test_config(
&self,
config: &crate::config::Config,
auth: Option<&CodexAuth>,
) -> Result<Vec<String>, RemotePluginFetchError>;
}
impl PluginsManagerTestConfigExt for PluginsManager {
async fn plugins_for_test_config(&self, config: &crate::config::Config) -> PluginLoadOutcome {
PluginsManager::plugins_for_config(
self,
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await
}
fn list_marketplaces_for_test_config(
&self,
config: &crate::config::Config,
additional_roots: &[AbsolutePathBuf],
) -> Result<ConfiguredMarketplaceListOutcome, MarketplaceError> {
PluginsManager::list_marketplaces_for_config(
self,
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
additional_roots,
)
}
async fn read_plugin_for_test_config(
&self,
config: &crate::config::Config,
request: &PluginReadRequest,
) -> Result<PluginReadOutcome, MarketplaceError> {
PluginsManager::read_plugin_for_config(
self,
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
request,
)
.await
}
async fn featured_plugin_ids_for_test_config(
&self,
config: &crate::config::Config,
auth: Option<&CodexAuth>,
) -> Result<Vec<String>, RemotePluginFetchError> {
PluginsManager::featured_plugin_ids(
self,
config.features.enabled(Feature::Plugins),
&config.chatgpt_base_url,
auth,
)
.await
}
}
#[tokio::test]
async fn load_plugins_loads_default_skills_and_mcp_servers() {
let codex_home = TempDir::new().unwrap();
@@ -359,7 +451,7 @@ remote_plugin = true
},
]);
let outcome = manager.plugins_for_config(&config).await;
let outcome = manager.plugins_for_test_config(&config).await;
assert_eq!(
outcome.effective_skill_roots(),
vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()]
@@ -390,7 +482,7 @@ remote_plugin = true
},
]);
let outcome = manager.plugins_for_config(&config).await;
let outcome = manager.plugins_for_test_config(&config).await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
@@ -1077,14 +1169,14 @@ async fn load_plugins_returns_empty_when_feature_disabled() {
let config = load_config(codex_home.path(), codex_home.path()).await;
let outcome = PluginsManager::new(codex_home.path().to_path_buf())
.plugins_for_config(&config)
.plugins_for_test_config(&config)
.await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
async fn plugins_for_test_config_reloads_when_plugin_hooks_enablement_changes() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
@@ -1118,7 +1210,7 @@ async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
);
let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let without_plugin_hooks = manager
.plugins_for_config(&config_without_plugin_hooks)
.plugins_for_test_config(&config_without_plugin_hooks)
.await;
assert!(
without_plugin_hooks
@@ -1134,7 +1226,9 @@ async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
),
);
let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await;
let with_plugin_hooks = manager
.plugins_for_test_config(&config_with_plugin_hooks)
.await;
assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1);
}
@@ -1179,7 +1273,7 @@ async fn load_plugins_rejects_invalid_plugin_keys() {
}
#[tokio::test]
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
async fn install_plugin_materializes_relative_path_plugin() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1226,10 +1320,6 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
auth_policy: MarketplacePluginAuthPolicy::OnUse,
}
);
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"));
}
#[tokio::test]
@@ -1430,7 +1520,7 @@ async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
}
#[tokio::test]
async fn uninstall_plugin_removes_cache_and_config_entry() {
async fn uninstall_plugin_removes_cache() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
@@ -1462,8 +1552,6 @@ enabled = true
.join("plugins/cache/debug/sample-plugin")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#));
}
#[tokio::test]
@@ -1520,7 +1608,10 @@ enabled = false
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.list_marketplaces_for_test_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.unwrap()
.marketplaces;
@@ -1616,7 +1707,10 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.list_marketplaces_for_test_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.unwrap()
.marketplaces;
@@ -1664,7 +1758,10 @@ plugins = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.list_marketplaces_for_test_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.unwrap()
.marketplaces;
@@ -1699,7 +1796,7 @@ plugins = true
}
#[tokio::test]
async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() {
async fn read_plugin_for_test_config_returns_plugins_disabled_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
@@ -1734,7 +1831,7 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let err = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
.read_plugin_for_test_config(
&config,
&PluginReadRequest {
plugin_name: "enabled-plugin".to_string(),
@@ -1748,7 +1845,7 @@ enabled = true
}
#[tokio::test]
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
async fn read_plugin_for_test_config_uses_user_layer_skill_settings_only() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("enabled-plugin");
@@ -1796,7 +1893,7 @@ enabled = false
let config = load_config(tmp.path(), &repo_root).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
.read_plugin_for_test_config(
&config,
&PluginReadRequest {
plugin_name: "enabled-plugin".to_string(),
@@ -1813,7 +1910,7 @@ enabled = false
}
#[tokio::test]
async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() {
async fn read_plugin_for_test_config_uninstalled_git_source_requires_install_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
@@ -1852,7 +1949,7 @@ plugins = true
let config = load_config(tmp.path(), &repo_root).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
.read_plugin_for_test_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
@@ -1888,7 +1985,7 @@ plugins = true
}
#[tokio::test]
async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() {
async fn read_plugin_for_test_config_installed_git_source_reads_from_cache_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
@@ -1950,7 +2047,7 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
.read_plugin_for_test_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
@@ -1991,25 +2088,6 @@ enabled = true
);
}
#[tokio::test]
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let config = load_config(tmp.path(), tmp.path()).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false)
.await
.unwrap();
assert_eq!(outcome, RemotePluginSyncResult::default());
}
#[tokio::test]
async fn list_marketplaces_includes_curated_repo_marketplace() {
let tmp = tempfile::tempdir().unwrap();
@@ -2048,7 +2126,7 @@ plugins = true
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.list_marketplaces_for_test_config(&config, &[])
.unwrap()
.marketplaces;
@@ -2125,7 +2203,7 @@ source = "/tmp/debug"
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.list_marketplaces_for_test_config(&config, &[])
.unwrap()
.marketplaces;
@@ -2201,7 +2279,7 @@ source = "/tmp/debug"
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.list_marketplaces_for_test_config(&config, &[])
.unwrap()
.marketplaces;
@@ -2256,7 +2334,7 @@ plugins = true
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[])
.list_marketplaces_for_test_config(&config, &[])
.unwrap()
.marketplaces;
@@ -2335,7 +2413,7 @@ enabled = false
let config = load_config(tmp.path(), &repo_a_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(
.list_marketplaces_for_test_config(
&config,
&[
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
@@ -2445,7 +2523,10 @@ enabled = true
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.list_marketplaces_for_test_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.unwrap()
.marketplaces;
@@ -2489,432 +2570,7 @@ enabled = true
}
#[tokio::test]
async fn sync_plugins_from_remote_reconciles_cache_and_config() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"gmail/local",
"gmail",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"calendar/local",
"calendar",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
[plugins."gmail@openai-curated"]
enabled = false
[plugins."calendar@openai-curated"]
enabled = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true},
{"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: vec!["linear@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: vec![
"gmail@openai-curated".to_string(),
"calendar@openai-curated".to_string(),
],
}
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/calendar")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
let synced_config = load_config(tmp.path(), tmp.path()).await;
let curated_marketplace = manager
.list_marketplaces_for_config(&synced_config, &[])
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
.unwrap();
assert_eq!(
curated_marketplace
.plugins
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![
("linear@openai-curated".to_string(), true, true),
("gmail@openai-curated".to_string(), false, false),
("calendar@openai-curated".to_string(), false, false),
]
);
}
#[tokio::test]
async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"gmail/local",
"gmail",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"calendar/local",
"calendar",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
[plugins."gmail@openai-curated"]
enabled = false
[plugins."calendar@openai-curated"]
enabled = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true},
{"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ true,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: vec!["linear@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: Vec::new(),
}
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/gmail/local")
.is_dir()
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/calendar/local")
.is_dir()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: Vec::new(),
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()],
}
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/linear")
.exists()
);
}
#[tokio::test]
async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap();
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let err = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap_err();
assert!(matches!(
err,
PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message))
if message.contains("plugin source path is not a directory")
));
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(config.contains("enabled = false"));
}
#[tokio::test]
async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
fs::write(
curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail-first"
}
},
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail-second"
}
}
]
}"#,
)
.unwrap();
write_plugin(&curated_root, "plugins/gmail-first", "gmail");
write_plugin(&curated_root, "plugins/gmail-second", "gmail");
fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap();
fs::write(
curated_root.join("plugins/gmail-second/marker.txt"),
"second",
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: vec!["gmail@openai-curated".to_string()],
enabled_plugin_ids: vec!["gmail@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: Vec::new(),
}
);
assert_eq!(
fs::read_to_string(tmp.path().join(format!(
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt"
)))
.unwrap(),
"first"
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
async fn featured_plugin_ids_for_test_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
@@ -2941,7 +2597,7 @@ plugins = true
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(
.featured_plugin_ids_for_test_config(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
@@ -2952,7 +2608,7 @@ plugins = true
}
#[tokio::test]
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
async fn featured_plugin_ids_for_test_config_defaults_query_param_to_codex() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
@@ -2977,7 +2633,7 @@ plugins = true
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, /*auth*/ None)
.featured_plugin_ids_for_test_config(&config, /*auth*/ None)
.await
.unwrap();
+7 -1
View File
@@ -23,6 +23,7 @@ use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::loader::load_config_layers_state;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::review_prompts::resolve_review_request;
@@ -621,7 +622,12 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec<PathBuf>, for
}
};
let effective_skill_roots = plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &config)
.effective_skill_roots_for_layer_stack(
&config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await;
let skills_input = crate::SkillsLoadInput::new(
cwd_abs.clone(),
+22 -3
View File
@@ -475,7 +475,14 @@ impl Codex {
let fs = environment
.as_ref()
.map(|environment| environment.get_filesystem());
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
let plugin_outcome = plugins_manager
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await;
@@ -2666,7 +2673,12 @@ impl Session {
let loaded_plugins = self
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(
&turn_context.config.config_layer_stack,
turn_context.config.features.enabled(Feature::Plugins),
turn_context.config.features.enabled(Feature::RemotePlugin),
turn_context.config.features.enabled(Feature::PluginHooks),
)
.await;
if let Some(plugin_instructions) =
AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries())
@@ -3362,7 +3374,14 @@ async fn build_hooks_for_config(
let _ = hook_shell_argv.pop();
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
let plugin_outcome = plugins_manager.plugins_for_config(config).await;
let plugin_outcome = plugins_manager
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await;
(
plugin_outcome.effective_plugin_hook_sources(),
plugin_outcome.effective_plugin_hook_warnings(),
+12 -2
View File
@@ -3558,7 +3558,12 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(
&per_turn_config.config_layer_stack,
per_turn_config.features.enabled(Feature::Plugins),
per_turn_config.features.enabled(Feature::RemotePlugin),
per_turn_config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
@@ -4987,7 +4992,12 @@ where
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(
&per_turn_config.config_layer_stack,
per_turn_config.features.enabled(Feature::Plugins),
per_turn_config.features.enabled(Feature::RemotePlugin),
per_turn_config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
+12 -2
View File
@@ -170,7 +170,12 @@ pub(crate) async fn run_turn(
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(
&turn_context.config.config_layer_stack,
turn_context.config.features.enabled(Feature::Plugins),
turn_context.config.features.enabled(Feature::RemotePlugin),
turn_context.config.features.enabled(Feature::PluginHooks),
)
.await;
// Structured plugin:// mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
@@ -1124,7 +1129,12 @@ pub(crate) async fn built_tools(
let loaded_plugins = sess
.services
.plugins_manager
.plugins_for_config(&turn_context.config)
.plugins_for_config(
&turn_context.config.config_layer_stack,
turn_context.config.features.enabled(Feature::Plugins),
turn_context.config.features.enabled(Feature::RemotePlugin),
turn_context.config.features.enabled(Feature::PluginHooks),
)
.await;
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
+6 -1
View File
@@ -696,7 +696,12 @@ impl Session {
let plugin_outcome = self
.services
.plugins_manager
.plugins_for_config(&per_turn_config)
.plugins_for_config(
&per_turn_config.config_layer_stack,
per_turn_config.features.enabled(Feature::Plugins),
per_turn_config.features.enabled(Feature::RemotePlugin),
per_turn_config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
+10 -1
View File
@@ -8,6 +8,8 @@ use tokio::runtime::Handle;
use tokio::sync::broadcast;
use tracing::warn;
use codex_features::Feature;
use crate::SkillsManager;
use crate::config::Config;
use crate::file_watcher::FileWatcher;
@@ -61,7 +63,14 @@ impl SkillsWatcher {
plugins_manager: &PluginsManager,
fs: Option<Arc<dyn codex_exec_server::ExecutorFileSystem>>,
) -> WatchRegistration {
let plugin_outcome = plugins_manager.plugins_for_config(config).await;
let plugin_outcome = plugins_manager
.plugins_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
config.features.enabled(Feature::RemotePlugin),
config.features.enabled(Feature::PluginHooks),
)
.await;
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(config, effective_skill_roots);
let roots = skills_manager
@@ -2,6 +2,7 @@ use std::collections::HashSet;
use codex_app_server_protocol::AppInfo;
use codex_config::types::ToolSuggestDisabledTool;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
@@ -317,7 +318,11 @@ fn verified_plugin_suggestion_completed(
plugins_manager: &crate::plugins::PluginsManager,
) -> bool {
plugins_manager
.list_marketplaces_for_config(config, &[])
.list_marketplaces_for_config(
&config.config_layer_stack,
config.features.enabled(Feature::Plugins),
&[],
)
.ok()
.into_iter()
.flat_map(|outcome| outcome.marketplaces)
@@ -1,4 +1,5 @@
use super::*;
use crate::config::edit::ConfigEditsBuilder;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::load_plugins_config;
@@ -48,6 +49,11 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
})
.await
.expect("plugin should install");
ConfigEditsBuilder::new(codex_home.path())
.set_plugin_enabled("sample@openai-curated", /*enabled*/ true)
.apply()
.await
.expect("plugin config should persist");
let refreshed_config = load_plugins_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(