mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Move plugin manager out of core
This commit is contained in:
Generated
+5
@@ -2100,6 +2100,7 @@ dependencies = [
|
||||
"codex-app-server-protocol",
|
||||
"codex-connectors",
|
||||
"codex-core",
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-login",
|
||||
"codex-model-provider",
|
||||
@@ -2503,6 +2504,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"codex-analytics",
|
||||
"codex-app-server-protocol",
|
||||
"codex-config",
|
||||
"codex-core-skills",
|
||||
@@ -2513,6 +2515,7 @@ dependencies = [
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-plugins",
|
||||
"dirs",
|
||||
@@ -2528,6 +2531,8 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tracing-test",
|
||||
"url",
|
||||
"wiremock",
|
||||
"zip 2.4.2",
|
||||
|
||||
@@ -6304,7 +6304,12 @@ impl CodexMessageProcessor {
|
||||
.map_or(&[][..], std::vec::Vec::as_slice);
|
||||
let effective_skill_roots = if workspace_codex_plugins_enabled {
|
||||
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
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -6389,7 +6394,8 @@ impl CodexMessageProcessor {
|
||||
plugins_manager
|
||||
.plugins_for_layer_stack(
|
||||
&config.config_layer_stack,
|
||||
&config,
|
||||
plugins_enabled,
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
/*plugin_hooks_feature_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
@@ -6452,10 +6458,11 @@ impl CodexMessageProcessor {
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let MarketplaceUpgradeParams { marketplace_name } = params;
|
||||
let config_layer_stack = config.config_layer_stack.clone();
|
||||
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
plugins_manager
|
||||
.upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref())
|
||||
.upgrade_configured_marketplaces(&config_layer_stack, marketplace_name.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))?
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
pub(super) async fn plugin_list(
|
||||
@@ -37,18 +38,24 @@ impl CodexMessageProcessor {
|
||||
{
|
||||
return Ok(empty_response());
|
||||
}
|
||||
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
|
||||
&config,
|
||||
plugins_manager.maybe_start_plugin_list_background_tasks(
|
||||
config.features.enabled(Feature::Plugins),
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth.clone(),
|
||||
&roots,
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
|
||||
let config_for_marketplace_listing = config.clone();
|
||||
let config_layer_stack_for_marketplace_listing = config.config_layer_stack.clone();
|
||||
let plugins_enabled_for_marketplace_listing = config.features.enabled(Feature::Plugins);
|
||||
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
|
||||
let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
|
||||
let outcome = plugins_manager_for_marketplace_listing
|
||||
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
|
||||
let outcome = plugins_manager_for_marketplace_listing.list_marketplaces_for_config(
|
||||
&config_layer_stack_for_marketplace_listing,
|
||||
plugins_enabled_for_marketplace_listing,
|
||||
&roots,
|
||||
)?;
|
||||
Ok::<
|
||||
(
|
||||
Vec<PluginMarketplaceEntry>,
|
||||
@@ -145,7 +152,11 @@ impl CodexMessageProcessor {
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
{
|
||||
match plugins_manager
|
||||
.featured_plugin_ids_for_config(&config, auth.as_ref())
|
||||
.featured_plugin_ids(
|
||||
config.features.enabled(Feature::Plugins),
|
||||
&config.chatgpt_base_url,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(featured_plugin_ids) => featured_plugin_ids,
|
||||
@@ -209,7 +220,11 @@ impl CodexMessageProcessor {
|
||||
marketplace_path,
|
||||
};
|
||||
let outcome = plugins_manager
|
||||
.read_plugin_for_config(&config, &request)
|
||||
.read_plugin_for_config(
|
||||
&config.config_layer_stack,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
&request,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
|
||||
let environment_manager = self.thread_manager.environment_manager();
|
||||
@@ -465,6 +480,14 @@ impl CodexMessageProcessor {
|
||||
.install_plugin(request)
|
||||
.await
|
||||
.map_err(Self::plugin_install_error)?;
|
||||
let installed_plugin_id = result.plugin_id.as_key();
|
||||
ConfigEditsBuilder::new(config.codex_home.as_path())
|
||||
.set_plugin_enabled(&installed_plugin_id, /*enabled*/ true)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!("failed to persist installed plugin config: {err}"))
|
||||
})?;
|
||||
let config = match self.load_latest_config(config_cwd).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
@@ -489,7 +512,7 @@ impl CodexMessageProcessor {
|
||||
.plugin_apps_needing_auth_for_install(
|
||||
&config,
|
||||
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
|
||||
&result.plugin_id.as_key(),
|
||||
&installed_plugin_id,
|
||||
&plugin_apps,
|
||||
)
|
||||
.await;
|
||||
@@ -570,7 +593,9 @@ impl CodexMessageProcessor {
|
||||
self.thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
@@ -688,9 +713,14 @@ impl CodexMessageProcessor {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
|
||||
plugins_manager
|
||||
.uninstall_plugin(plugin_id)
|
||||
.uninstall_plugin(plugin_id.clone())
|
||||
.await
|
||||
.map_err(Self::plugin_uninstall_error)?;
|
||||
ConfigEditsBuilder::new(self.config.codex_home.as_path())
|
||||
.clear_plugin(&plugin_id)
|
||||
.apply()
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to clear plugin config: {err}")))?;
|
||||
match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => self.on_effective_plugins_changed(config),
|
||||
Err(err) => {
|
||||
@@ -712,9 +742,6 @@ impl CodexMessageProcessor {
|
||||
CorePluginInstallError::Marketplace(err) => {
|
||||
Self::marketplace_error(err, "install plugin")
|
||||
}
|
||||
CorePluginInstallError::Config(err) => {
|
||||
internal_error(format!("failed to persist installed plugin config: {err}"))
|
||||
}
|
||||
CorePluginInstallError::Remote(err) => {
|
||||
internal_error(format!("failed to enable remote plugin: {err}"))
|
||||
}
|
||||
@@ -733,9 +760,6 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
match err {
|
||||
CorePluginUninstallError::Config(err) => {
|
||||
internal_error(format!("failed to clear plugin config: {err}"))
|
||||
}
|
||||
CorePluginUninstallError::Remote(err) => {
|
||||
internal_error(format!("failed to uninstall remote plugin: {err}"))
|
||||
}
|
||||
@@ -798,7 +822,9 @@ impl CodexMessageProcessor {
|
||||
self.on_effective_plugins_changed(config.clone());
|
||||
}
|
||||
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback(config.clone())),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::plugins::PluginId;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
@@ -20,6 +21,7 @@ use codex_external_agent_migration::missing_command_names;
|
||||
use codex_external_agent_migration::missing_subagent_names;
|
||||
use codex_external_agent_sessions::ExternalAgentSessionMigration;
|
||||
use codex_external_agent_sessions::detect_recent_sessions;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -757,9 +759,16 @@ impl ExternalAgentConfigService {
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => outcome
|
||||
.succeeded_plugin_ids
|
||||
.push(format!("{plugin_name}@{marketplace_name}")),
|
||||
Ok(result) => match ConfigEditsBuilder::new(self.codex_home.as_path())
|
||||
.set_plugin_enabled(&result.plugin_id.as_key(), /*enabled*/ true)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => outcome.succeeded_plugin_ids.push(result.plugin_id.as_key()),
|
||||
Err(_) => outcome
|
||||
.failed_plugin_ids
|
||||
.push(format!("{plugin_name}@{marketplace_name}")),
|
||||
},
|
||||
Err(_) => outcome
|
||||
.failed_plugin_ids
|
||||
.push(format!("{plugin_name}@{marketplace_name}")),
|
||||
@@ -1147,7 +1156,11 @@ fn configured_marketplace_plugins(
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> io::Result<BTreeMap<String, HashSet<String>>> {
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.list_marketplaces_for_config(
|
||||
&config.config_layer_stack,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
&[],
|
||||
)
|
||||
.map_err(|err| {
|
||||
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
|
||||
})?;
|
||||
|
||||
@@ -313,8 +313,11 @@ impl MessageProcessor {
|
||||
codex_message_processor.effective_plugins_changed_callback((*config).clone());
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_plugin_startup_tasks_for_config(
|
||||
&config,
|
||||
.maybe_start_plugin_startup_tasks(
|
||||
config.config_layer_stack.clone(),
|
||||
config.features.enabled(Feature::Plugins),
|
||||
config.features.enabled(Feature::RemotePlugin),
|
||||
config.chatgpt_base_url.clone(),
|
||||
auth_manager.clone(),
|
||||
Some(on_effective_plugins_changed),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,6 @@ use wiremock::matchers::query_param;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
@@ -1044,91 +1043,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_openai_curated_marketplace(codex_home.path(), &["linear"])?;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/list"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.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;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let marker_path = codex_home
|
||||
.path()
|
||||
.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
|
||||
|
||||
{
|
||||
let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
wait_for_path_exists(&marker_path).await?;
|
||||
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
|
||||
.await?;
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
let curated_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "openai-curated")
|
||||
.expect("expected openai-curated marketplace entry");
|
||||
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)]
|
||||
);
|
||||
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
|
||||
{
|
||||
let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1579,19 +1493,6 @@ async fn wait_for_remote_plugin_request_count(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
if path.exists() {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_installed_plugin(
|
||||
codex_home: &TempDir,
|
||||
marketplace_name: &str,
|
||||
|
||||
@@ -13,6 +13,7 @@ clap = { workspace = true, features = ["derive"] }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
use codex_core::plugins::AppConnectorId;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::originator;
|
||||
@@ -137,7 +138,12 @@ fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsC
|
||||
|
||||
async fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
|
||||
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
|
||||
.effective_apps()
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ async fn run_upgrade(
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let manager = PluginsManager::new(codex_home.to_path_buf());
|
||||
let outcome = manager
|
||||
.upgrade_configured_marketplaces_for_config(&config, marketplace_name.as_deref())
|
||||
.upgrade_configured_marketplaces(&config.config_layer_stack, marketplace_name.as_deref())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
print_upgrade_outcome(&outcome, marketplace_name.as_deref())
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+23
-17
@@ -1,15 +1,16 @@
|
||||
use anyhow::Context;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
use super::PluginCapabilitySummary;
|
||||
use super::PluginsManager;
|
||||
use crate::config::Config;
|
||||
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_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_plugin::PluginCapabilitySummary;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
|
||||
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
|
||||
@@ -17,30 +18,31 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
|
||||
OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
];
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
config: &Config,
|
||||
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 !config.features.enabled(Feature::Plugins) {
|
||||
if !plugins_enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let plugins_manager = PluginsManager::new(config.codex_home.to_path_buf());
|
||||
let configured_plugin_ids = config
|
||||
.tool_suggest
|
||||
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 = config
|
||||
.tool_suggest
|
||||
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, &[])
|
||||
.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();
|
||||
@@ -62,7 +64,11 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
|
||||
let plugin_id = plugin.id.clone();
|
||||
|
||||
match plugins_manager
|
||||
.read_plugin_detail_for_marketplace_plugin(config, &marketplace_name, plugin)
|
||||
.read_plugin_detail_for_marketplace_plugin(
|
||||
config_layer_stack,
|
||||
&marketplace_name,
|
||||
plugin,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(plugin) => {
|
||||
+219
-50
@@ -1,15 +1,19 @@
|
||||
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 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;
|
||||
@@ -22,10 +26,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plug
|
||||
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();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
|
||||
codex_home.path(),
|
||||
/*plugins_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
@@ -105,7 +111,7 @@ async fn list_tool_suggest_discoverable_plugins_deduplicates_allowlisted_configu
|
||||
);
|
||||
write_curated_plugin(&marketplace_root, plugin_name);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
&format!(
|
||||
r#"[features]
|
||||
plugins = true
|
||||
@@ -120,10 +126,12 @@ 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();
|
||||
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);
|
||||
@@ -159,7 +167,7 @@ async fn list_tool_suggest_discoverable_plugins_ignores_missing_allowlisted_plug
|
||||
),
|
||||
);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
&format!(
|
||||
r#"[features]
|
||||
plugins = true
|
||||
@@ -171,10 +179,12 @@ source = "/tmp/{marketplace_name}"
|
||||
),
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
.await
|
||||
.unwrap();
|
||||
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");
|
||||
@@ -186,16 +196,18 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu
|
||||
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),
|
||||
&codex_home.path().join(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();
|
||||
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());
|
||||
}
|
||||
@@ -214,10 +226,12 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
|
||||
}"#,
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
.await
|
||||
.unwrap();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
|
||||
codex_home.path(),
|
||||
/*plugins_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
@@ -240,7 +254,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
|
||||
write_curated_plugin_sha(codex_home.path());
|
||||
write_plugins_feature_config(codex_home.path());
|
||||
|
||||
PluginsManager::new(codex_home.path().to_path_buf())
|
||||
crate::manager::PluginsManager::new(codex_home.path().to_path_buf())
|
||||
.install_plugin(PluginInstallRequest {
|
||||
plugin_name: "slack".to_string(),
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
@@ -250,11 +264,22 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
|
||||
})
|
||||
.await
|
||||
.expect("plugin should install");
|
||||
write_file(
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
let refreshed_config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
|
||||
.await
|
||||
.unwrap();
|
||||
[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());
|
||||
}
|
||||
@@ -265,7 +290,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_disabled_tool_suggestions(
|
||||
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),
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
@@ -276,10 +301,12 @@ disabled_tools = [
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
.await
|
||||
.unwrap();
|
||||
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());
|
||||
}
|
||||
@@ -290,7 +317,7 @@ async fn list_tool_suggest_discoverable_plugins_includes_configured_plugin_ids()
|
||||
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),
|
||||
&codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
@@ -299,10 +326,12 @@ 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();
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins_for_test(
|
||||
codex_home.path(),
|
||||
/*plugins_enabled*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
discoverable_plugins,
|
||||
@@ -345,7 +374,6 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
);
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -357,9 +385,12 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
.await
|
||||
.unwrap();
|
||||
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");
|
||||
@@ -382,3 +413,141 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
|
||||
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
|
||||
"#,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+137
-481
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -233,7 +233,12 @@ impl App {
|
||||
|
||||
tokio::spawn(async move {
|
||||
let plugins = 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()
|
||||
.to_vec();
|
||||
|
||||
Reference in New Issue
Block a user