mirror of
https://github.com/openai/codex.git
synced 2026-05-05 05:42:33 +03:00
Extract plugin loading and marketplace logic into codex-core-plugins (#18070)
Split plugin loading, marketplace, and related infrastructure out of core into codex-core-plugins, while keeping the core-facing configuration and orchestration flow in codex-core. --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
6
codex-rs/core-plugins/src/lib.rs
Normal file
6
codex-rs/core-plugins/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod loader;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod remote;
|
||||
pub mod store;
|
||||
pub mod toggles;
|
||||
807
codex-rs/core-plugins/src/loader.rs
Normal file
807
codex-rs/core-plugins/src/loader.rs
Normal file
@@ -0,0 +1,807 @@
|
||||
use crate::manifest::PluginManifestPaths;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::list_marketplaces;
|
||||
use crate::marketplace::load_marketplace;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::plugin_version_for_source;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_core_skills::SkillMetadata;
|
||||
use codex_core_skills::config_rules::SkillConfigRules;
|
||||
use codex_core_skills::config_rules::resolve_disabled_skill_paths;
|
||||
use codex_core_skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::LoadedPlugin;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginLoadOutcome;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub fn log_plugin_load_errors(outcome: &PluginLoadOutcome<McpServerConfig>) {
|
||||
for plugin in outcome
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.error.is_some())
|
||||
{
|
||||
if let Some(error) = plugin.error.as_deref() {
|
||||
warn!(
|
||||
plugin = plugin.config_name,
|
||||
path = %plugin.root.display(),
|
||||
"failed to load plugin: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginMcpFile {
|
||||
#[serde(default)]
|
||||
mcp_servers: HashMap<String, JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginAppFile {
|
||||
#[serde(default)]
|
||||
apps: HashMap<String, PluginAppConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PluginAppConfig {
|
||||
id: String,
|
||||
}
|
||||
|
||||
pub async fn load_plugins_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
) -> PluginLoadOutcome<McpServerConfig> {
|
||||
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
|
||||
let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack)
|
||||
.into_iter()
|
||||
.collect();
|
||||
configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
let mut plugins = Vec::with_capacity(configured_plugins.len());
|
||||
let mut seen_mcp_server_names = HashMap::<String, String>::new();
|
||||
for (configured_name, plugin) in configured_plugins {
|
||||
let loaded_plugin = load_plugin(
|
||||
configured_name.clone(),
|
||||
&plugin,
|
||||
store,
|
||||
restriction_product,
|
||||
&skill_config_rules,
|
||||
)
|
||||
.await;
|
||||
for name in loaded_plugin.mcp_servers.keys() {
|
||||
if let Some(previous_plugin) =
|
||||
seen_mcp_server_names.insert(name.clone(), configured_name.clone())
|
||||
{
|
||||
warn!(
|
||||
plugin = configured_name,
|
||||
previous_plugin,
|
||||
server = name,
|
||||
"skipping duplicate plugin MCP server name"
|
||||
);
|
||||
}
|
||||
}
|
||||
plugins.push(loaded_plugin);
|
||||
}
|
||||
|
||||
PluginLoadOutcome::from_plugins(plugins)
|
||||
}
|
||||
|
||||
pub fn refresh_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
plugin_version: &str,
|
||||
configured_curated_plugin_ids: &[PluginId],
|
||||
) -> Result<bool, String> {
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
codex_home
|
||||
.join(".tmp/plugins")
|
||||
.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| "local curated marketplace is not available".to_string())?;
|
||||
let curated_marketplace = load_marketplace(&curated_marketplace_path)
|
||||
.map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?;
|
||||
|
||||
let mut plugin_sources = HashMap::<String, AbsolutePathBuf>::new();
|
||||
for plugin in curated_marketplace.plugins {
|
||||
let plugin_name = plugin.name;
|
||||
if plugin_sources.contains_key(&plugin_name) {
|
||||
warn!(
|
||||
plugin = plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"ignoring duplicate curated plugin entry during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
};
|
||||
plugin_sources.insert(plugin_name, source_path);
|
||||
}
|
||||
|
||||
let mut cache_refreshed = false;
|
||||
for plugin_id in configured_curated_plugin_ids {
|
||||
if store.active_plugin_version(plugin_id).as_deref() == Some(plugin_version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else {
|
||||
warn!(
|
||||
plugin = plugin_id.plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"configured curated plugin no longer exists in curated marketplace during cache refresh"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
store
|
||||
.install_with_version(source_path, plugin_id.clone(), plugin_version.to_string())
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to refresh curated plugin cache for {}: {err}",
|
||||
plugin_id.as_key()
|
||||
)
|
||||
})?;
|
||||
cache_refreshed = true;
|
||||
}
|
||||
|
||||
Ok(cache_refreshed)
|
||||
}
|
||||
|
||||
pub fn refresh_non_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<bool, String> {
|
||||
let configured_non_curated_plugin_ids =
|
||||
non_curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
||||
codex_home,
|
||||
"failed to read user config while refreshing non-curated plugin cache",
|
||||
"failed to parse user config while refreshing non-curated plugin cache",
|
||||
));
|
||||
if configured_non_curated_plugin_ids.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let configured_non_curated_plugin_keys = configured_non_curated_plugin_ids
|
||||
.iter()
|
||||
.map(PluginId::as_key)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let marketplace_outcome = list_marketplaces(additional_roots)
|
||||
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
||||
let mut plugin_sources = HashMap::<String, (AbsolutePathBuf, String)>::new();
|
||||
|
||||
for marketplace in marketplace_outcome.marketplaces {
|
||||
if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let plugin_id =
|
||||
PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(|err| {
|
||||
match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
format!("failed to prepare non-curated plugin cache refresh: {message}")
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
if !configured_non_curated_plugin_keys.contains(&plugin_key) {
|
||||
continue;
|
||||
}
|
||||
if plugin_sources.contains_key(&plugin_key) {
|
||||
warn!(
|
||||
plugin = plugin.name,
|
||||
marketplace = marketplace.name,
|
||||
"ignoring duplicate non-curated plugin entry during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
};
|
||||
let plugin_version = plugin_version_for_source(source_path.as_path())
|
||||
.map_err(|err| format!("failed to read plugin version for {plugin_key}: {err}"))?;
|
||||
plugin_sources.insert(plugin_key, (source_path, plugin_version));
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache_refreshed = false;
|
||||
for plugin_id in configured_non_curated_plugin_ids {
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let Some((source_path, plugin_version)) = plugin_sources.get(&plugin_key).cloned() else {
|
||||
warn!(
|
||||
plugin = plugin_id.plugin_name,
|
||||
marketplace = plugin_id.marketplace_name,
|
||||
"configured non-curated plugin no longer exists in discovered marketplaces during cache refresh"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if store.active_plugin_version(&plugin_id).as_deref() == Some(plugin_version.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
store
|
||||
.install_with_version(source_path, plugin_id.clone(), plugin_version)
|
||||
.map_err(|err| format!("failed to refresh plugin cache for {plugin_key}: {err}"))?;
|
||||
cache_refreshed = true;
|
||||
}
|
||||
|
||||
Ok(cache_refreshed)
|
||||
}
|
||||
|
||||
fn configured_plugins_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
let Some(user_layer) = config_layer_stack.get_user_layer() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
configured_plugins_from_user_config_value(&user_layer.config)
|
||||
}
|
||||
|
||||
fn configured_plugins_from_user_config_value(
|
||||
user_config: &toml::Value,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
let Some(plugins_value) = user_config.get("plugins") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
match plugins_value.clone().try_into() {
|
||||
Ok(plugins) => plugins,
|
||||
Err(err) => {
|
||||
warn!("invalid plugins config: {err}");
|
||||
HashMap::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_plugins_from_codex_home(
|
||||
codex_home: &Path,
|
||||
read_error_message: &str,
|
||||
parse_error_message: &str,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let user_config = match fs::read_to_string(&config_path) {
|
||||
Ok(user_config) => user_config,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %config_path.display(),
|
||||
error = %err,
|
||||
"{read_error_message}"
|
||||
);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let user_config = match toml::from_str::<toml::Value>(&user_config) {
|
||||
Ok(user_config) => user_config,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %config_path.display(),
|
||||
error = %err,
|
||||
"{parse_error_message}"
|
||||
);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
configured_plugins_from_user_config_value(&user_config)
|
||||
}
|
||||
|
||||
fn configured_plugin_ids(
|
||||
configured_plugins: HashMap<String, PluginConfig>,
|
||||
invalid_plugin_key_message: &str,
|
||||
) -> Vec<PluginId> {
|
||||
configured_plugins
|
||||
.into_keys()
|
||||
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
|
||||
Ok(plugin_id) => Some(plugin_id),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_key,
|
||||
error = %err,
|
||||
"{invalid_plugin_key_message}"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn curated_plugin_ids_from_config_keys(
|
||||
configured_plugins: HashMap<String, PluginConfig>,
|
||||
) -> Vec<PluginId> {
|
||||
let mut configured_curated_plugin_ids = configured_plugin_ids(
|
||||
configured_plugins,
|
||||
"ignoring invalid configured plugin key during curated sync setup",
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|plugin_id| plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
configured_curated_plugin_ids
|
||||
}
|
||||
|
||||
fn non_curated_plugin_ids_from_config_keys(
|
||||
configured_plugins: HashMap<String, PluginConfig>,
|
||||
) -> Vec<PluginId> {
|
||||
let mut configured_non_curated_plugin_ids = configured_plugin_ids(
|
||||
configured_plugins,
|
||||
"ignoring invalid plugin key during non-curated cache refresh setup",
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|plugin_id| plugin_id.marketplace_name != OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
.collect::<Vec<_>>();
|
||||
configured_non_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
configured_non_curated_plugin_ids
|
||||
}
|
||||
|
||||
pub fn configured_curated_plugin_ids_from_codex_home(codex_home: &Path) -> Vec<PluginId> {
|
||||
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
||||
codex_home,
|
||||
"failed to read user config while refreshing curated plugin cache",
|
||||
"failed to parse user config while refreshing curated plugin cache",
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_plugin(
|
||||
config_name: String,
|
||||
plugin: &PluginConfig,
|
||||
store: &PluginStore,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> LoadedPlugin<McpServerConfig> {
|
||||
let plugin_id = PluginId::parse(&config_name);
|
||||
let active_plugin_root = plugin_id
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|plugin_id| store.active_plugin_root(plugin_id));
|
||||
let root = active_plugin_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| match &plugin_id {
|
||||
Ok(plugin_id) => store.plugin_base_root(plugin_id),
|
||||
Err(_) => store.root().clone(),
|
||||
});
|
||||
let mut loaded_plugin = LoadedPlugin {
|
||||
config_name,
|
||||
manifest_name: None,
|
||||
manifest_description: None,
|
||||
root,
|
||||
enabled: plugin.enabled,
|
||||
skill_roots: Vec::new(),
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
apps: Vec::new(),
|
||||
error: None,
|
||||
};
|
||||
|
||||
if !plugin.enabled {
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let plugin_root = match plugin_id {
|
||||
Ok(_) => match active_plugin_root {
|
||||
Some(plugin_root) => plugin_root,
|
||||
None => {
|
||||
loaded_plugin.error = Some("plugin is not installed".to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
loaded_plugin.error = Some(err.to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
};
|
||||
|
||||
if !plugin_root.as_path().is_dir() {
|
||||
loaded_plugin.error = Some("path does not exist or is not a directory".to_string());
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
loaded_plugin.error = Some("missing or invalid .codex-plugin/plugin.json".to_string());
|
||||
return loaded_plugin;
|
||||
};
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
loaded_plugin.manifest_name = manifest
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|display_name| !display_name.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| Some(manifest.name.clone()));
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
&plugin_root,
|
||||
manifest_paths,
|
||||
restriction_product,
|
||||
skill_config_rules,
|
||||
)
|
||||
.await;
|
||||
let has_enabled_skills = resolved_skills.has_enabled_skills();
|
||||
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
|
||||
loaded_plugin.has_enabled_skills = has_enabled_skills;
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root.as_path(), &mcp_config_path).await;
|
||||
for (name, config) in plugin_mcp.mcp_servers {
|
||||
if mcp_servers.insert(name.clone(), config).is_some() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
path = %mcp_config_path.display(),
|
||||
server = name,
|
||||
"plugin MCP file overwrote an earlier server definition"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded_plugin.mcp_servers = mcp_servers;
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
loaded_plugin
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPluginSkills {
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub had_errors: bool,
|
||||
}
|
||||
|
||||
impl ResolvedPluginSkills {
|
||||
pub fn has_enabled_skills(&self) -> bool {
|
||||
self.had_errors
|
||||
|| self
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| !self.disabled_skill_paths.contains(&skill.path_to_skills_md))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_plugin_skills(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
restriction_product: Option<Product>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ResolvedPluginSkills {
|
||||
let roots = plugin_skill_roots(plugin_root, manifest_paths)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
file_system: Arc::clone(&LOCAL_FS),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let outcome = load_skills_from_roots(roots).await;
|
||||
let had_errors = !outcome.errors.is_empty();
|
||||
let skills = outcome
|
||||
.skills
|
||||
.into_iter()
|
||||
.filter(|skill| skill.matches_product_restriction_for_product(restriction_product))
|
||||
.collect::<Vec<_>>();
|
||||
let disabled_skill_paths = resolve_disabled_skill_paths(&skills, skill_config_rules);
|
||||
|
||||
ResolvedPluginSkills {
|
||||
skills,
|
||||
disabled_skill_paths,
|
||||
had_errors,
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_skill_roots(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = default_skill_roots(plugin_root);
|
||||
if let Some(path) = &manifest_paths.skills {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
paths
|
||||
}
|
||||
|
||||
fn default_skill_roots(plugin_root: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
|
||||
let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME);
|
||||
if skills_dir.is_dir() {
|
||||
vec![skills_dir]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_mcp_config_paths(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
if let Some(path) = &manifest_paths.mcp_servers {
|
||||
return vec![path.clone()];
|
||||
}
|
||||
default_mcp_config_paths(plugin_root)
|
||||
}
|
||||
|
||||
fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let default_path = plugin_root.join(DEFAULT_MCP_CONFIG_FILE);
|
||||
if default_path.is_file()
|
||||
&& let Ok(default_path) = AbsolutePathBuf::try_from(default_path)
|
||||
{
|
||||
paths.push(default_path);
|
||||
}
|
||||
paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
|
||||
paths.dedup_by(|left, right| left.as_path() == right.as_path());
|
||||
paths
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_apps_from_paths(
|
||||
plugin_root,
|
||||
plugin_app_config_paths(plugin_root, &manifest.paths),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
|
||||
fn plugin_app_config_paths(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
if let Some(path) = &manifest_paths.apps {
|
||||
return vec![path.clone()];
|
||||
}
|
||||
default_app_config_paths(plugin_root)
|
||||
}
|
||||
|
||||
fn default_app_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let default_path = plugin_root.join(DEFAULT_APP_CONFIG_FILE);
|
||||
if default_path.is_file()
|
||||
&& let Ok(default_path) = AbsolutePathBuf::try_from(default_path)
|
||||
{
|
||||
paths.push(default_path);
|
||||
}
|
||||
paths.sort_unstable_by(|left, right| left.as_path().cmp(right.as_path()));
|
||||
paths.dedup_by(|left, right| left.as_path() == right.as_path());
|
||||
paths
|
||||
}
|
||||
|
||||
async fn load_apps_from_paths(
|
||||
plugin_root: &Path,
|
||||
app_config_paths: Vec<AbsolutePathBuf>,
|
||||
) -> Vec<AppConnectorId> {
|
||||
let mut connector_ids = Vec::new();
|
||||
for app_config_path in app_config_paths {
|
||||
let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else {
|
||||
continue;
|
||||
};
|
||||
let parsed = match serde_json::from_str::<PluginAppFile>(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %app_config_path.display(),
|
||||
"failed to parse plugin app config: {err}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut apps: Vec<PluginAppConfig> = parsed.apps.into_values().collect();
|
||||
apps.sort_unstable_by(|left, right| left.id.cmp(&right.id));
|
||||
|
||||
connector_ids.extend(apps.into_iter().filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(AppConnectorId(app.id))
|
||||
}
|
||||
}));
|
||||
}
|
||||
connector_ids.dedup();
|
||||
connector_ids
|
||||
}
|
||||
|
||||
pub async fn plugin_telemetry_metadata_from_root(
|
||||
plugin_id: &PluginId,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
|
||||
let mut mcp_server_names = Vec::new();
|
||||
for path in plugin_mcp_config_paths(plugin_root.as_path(), manifest_paths) {
|
||||
mcp_server_names.extend(
|
||||
load_mcp_servers_from_file(plugin_root.as_path(), &path)
|
||||
.await
|
||||
.mcp_servers
|
||||
.into_keys(),
|
||||
);
|
||||
}
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
description: None,
|
||||
has_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: load_apps_from_paths(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String, McpServerConfig> {
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root, &manifest.paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await;
|
||||
for (name, config) in plugin_mcp.mcp_servers {
|
||||
mcp_servers.entry(name).or_insert(config);
|
||||
}
|
||||
}
|
||||
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub async fn installed_plugin_telemetry_metadata(
|
||||
codex_home: &Path,
|
||||
plugin_id: &PluginId,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let Some(plugin_root) = store.active_plugin_root(plugin_id) else {
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
|
||||
plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await
|
||||
}
|
||||
|
||||
async fn load_mcp_servers_from_file(
|
||||
plugin_root: &Path,
|
||||
mcp_config_path: &AbsolutePathBuf,
|
||||
) -> PluginMcpDiscovery {
|
||||
let Ok(contents) = tokio::fs::read_to_string(mcp_config_path.as_path()).await else {
|
||||
return PluginMcpDiscovery::default();
|
||||
};
|
||||
let parsed = match serde_json::from_str::<PluginMcpFile>(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"failed to parse plugin MCP config: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
normalize_plugin_mcp_servers(
|
||||
plugin_root,
|
||||
parsed.mcp_servers,
|
||||
mcp_config_path.to_string_lossy().as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_plugin_mcp_servers(
|
||||
plugin_root: &Path,
|
||||
plugin_mcp_servers: HashMap<String, JsonValue>,
|
||||
source: &str,
|
||||
) -> PluginMcpDiscovery {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
|
||||
for (name, config_value) in plugin_mcp_servers {
|
||||
let normalized = normalize_plugin_mcp_server_value(plugin_root, config_value);
|
||||
match serde_json::from_value::<McpServerConfig>(JsonValue::Object(normalized)) {
|
||||
Ok(config) => {
|
||||
mcp_servers.insert(name, config);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
server = name,
|
||||
"failed to parse plugin MCP server from {source}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PluginMcpDiscovery { mcp_servers }
|
||||
}
|
||||
|
||||
fn normalize_plugin_mcp_server_value(
|
||||
plugin_root: &Path,
|
||||
value: JsonValue,
|
||||
) -> JsonMap<String, JsonValue> {
|
||||
let mut object = match value {
|
||||
JsonValue::Object(object) => object,
|
||||
_ => return JsonMap::new(),
|
||||
};
|
||||
|
||||
if let Some(JsonValue::String(transport_type)) = object.remove("type") {
|
||||
match transport_type.as_str() {
|
||||
"http" | "streamable_http" | "streamable-http" => {}
|
||||
"stdio" => {}
|
||||
other => {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
transport = other,
|
||||
"plugin MCP server uses an unknown transport type"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(JsonValue::Object(oauth)) = object.remove("oauth")
|
||||
&& oauth.contains_key("callbackPort")
|
||||
{
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin MCP server OAuth callbackPort is ignored; Codex uses global MCP OAuth callback settings"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(JsonValue::String(cwd)) = object.get("cwd")
|
||||
&& !Path::new(cwd).is_absolute()
|
||||
{
|
||||
object.insert(
|
||||
"cwd".to_string(),
|
||||
JsonValue::String(plugin_root.join(cwd).display().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
object
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PluginMcpDiscovery {
|
||||
mcp_servers: HashMap<String, McpServerConfig>,
|
||||
}
|
||||
509
codex-rs/core-plugins/src/manifest.rs
Normal file
509
codex-rs/core-plugins/src/manifest.rs
Normal file
@@ -0,0 +1,509 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifest {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
|
||||
// resolving them under the plugin root.
|
||||
#[serde(default)]
|
||||
skills: Option<String>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Option<String>,
|
||||
#[serde(default)]
|
||||
apps: Option<String>,
|
||||
#[serde(default)]
|
||||
interface: Option<RawPluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifest {
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub paths: PluginManifestPaths,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifestPaths {
|
||||
pub skills: Option<AbsolutePathBuf>,
|
||||
pub mcp_servers: Option<AbsolutePathBuf>,
|
||||
pub apps: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginManifestInterface {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub long_description: Option<String>,
|
||||
pub developer_name: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
pub screenshots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
short_description: Option<String>,
|
||||
#[serde(default)]
|
||||
long_description: Option<String>,
|
||||
#[serde(default)]
|
||||
developer_name: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "websiteURL")]
|
||||
website_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "privacyPolicyURL")]
|
||||
privacy_policy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<RawPluginManifestDefaultPrompt>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
composer_icon: Option<String>,
|
||||
#[serde(default)]
|
||||
logo: Option<String>,
|
||||
#[serde(default)]
|
||||
screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawPluginManifestDefaultPrompt {
|
||||
String(String),
|
||||
List(Vec<RawPluginManifestDefaultPromptEntry>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawPluginManifestDefaultPromptEntry {
|
||||
String(String),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
match serde_json::from_str::<RawPluginManifest>(&contents) {
|
||||
Ok(manifest) => {
|
||||
let RawPluginManifest {
|
||||
name: raw_name,
|
||||
version,
|
||||
description,
|
||||
skills,
|
||||
mcp_servers,
|
||||
apps,
|
||||
interface,
|
||||
} = manifest;
|
||||
let name = plugin_root
|
||||
.file_name()
|
||||
.and_then(|entry| entry.to_str())
|
||||
.filter(|_| raw_name.trim().is_empty())
|
||||
.unwrap_or(&raw_name)
|
||||
.to_string();
|
||||
let version = version.and_then(|version| {
|
||||
let version = version.trim();
|
||||
(!version.is_empty()).then(|| version.to_string())
|
||||
});
|
||||
let interface = interface.and_then(|interface| {
|
||||
let RawPluginManifestInterface {
|
||||
display_name,
|
||||
short_description,
|
||||
long_description,
|
||||
developer_name,
|
||||
category,
|
||||
capabilities,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
terms_of_service_url,
|
||||
default_prompt,
|
||||
brand_color,
|
||||
composer_icon,
|
||||
logo,
|
||||
screenshots,
|
||||
} = interface;
|
||||
|
||||
let interface = PluginManifestInterface {
|
||||
display_name,
|
||||
short_description,
|
||||
long_description,
|
||||
developer_name,
|
||||
category,
|
||||
capabilities,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
terms_of_service_url,
|
||||
default_prompt: resolve_default_prompts(plugin_root, default_prompt.as_ref()),
|
||||
brand_color,
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.composerIcon",
|
||||
composer_icon.as_deref(),
|
||||
),
|
||||
logo: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.logo",
|
||||
logo.as_deref(),
|
||||
),
|
||||
screenshots: screenshots
|
||||
.iter()
|
||||
.filter_map(|screenshot| {
|
||||
resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.screenshots",
|
||||
Some(screenshot),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let has_fields = interface.display_name.is_some()
|
||||
|| interface.short_description.is_some()
|
||||
|| interface.long_description.is_some()
|
||||
|| interface.developer_name.is_some()
|
||||
|| interface.category.is_some()
|
||||
|| !interface.capabilities.is_empty()
|
||||
|| interface.website_url.is_some()
|
||||
|| interface.privacy_policy_url.is_some()
|
||||
|| interface.terms_of_service_url.is_some()
|
||||
|| interface.default_prompt.is_some()
|
||||
|| interface.brand_color.is_some()
|
||||
|| interface.composer_icon.is_some()
|
||||
|| interface.logo.is_some()
|
||||
|| !interface.screenshots.is_empty();
|
||||
|
||||
has_fields.then_some(interface)
|
||||
});
|
||||
Some(PluginManifest {
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
paths: PluginManifestPaths {
|
||||
skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()),
|
||||
mcp_servers: resolve_manifest_path(
|
||||
plugin_root,
|
||||
"mcpServers",
|
||||
mcp_servers.as_deref(),
|
||||
),
|
||||
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
|
||||
},
|
||||
interface,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"failed to parse plugin manifest: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interface_asset_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
resolve_manifest_path(plugin_root, field, path)
|
||||
}
|
||||
|
||||
fn resolve_default_prompts(
|
||||
plugin_root: &Path,
|
||||
value: Option<&RawPluginManifestDefaultPrompt>,
|
||||
) -> Option<Vec<String>> {
|
||||
match value? {
|
||||
RawPluginManifestDefaultPrompt::String(prompt) => {
|
||||
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
|
||||
.map(|prompt| vec![prompt])
|
||||
}
|
||||
RawPluginManifestDefaultPrompt::List(values) => {
|
||||
let mut prompts = Vec::new();
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
match item {
|
||||
RawPluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
if let Some(prompt) =
|
||||
resolve_default_prompt_str(plugin_root, &field, prompt)
|
||||
{
|
||||
prompts.push(prompt);
|
||||
}
|
||||
}
|
||||
RawPluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
&field,
|
||||
&format!("expected a string, found {}", json_value_type(value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!prompts.is_empty()).then_some(prompts)
|
||||
}
|
||||
RawPluginManifestDefaultPrompt::Invalid(value) => {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!(
|
||||
"expected a string or array of strings, found {}",
|
||||
json_value_type(value)
|
||||
),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
|
||||
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if prompt.is_empty() {
|
||||
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
|
||||
return None;
|
||||
}
|
||||
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
field,
|
||||
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"ignoring {field}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn json_value_type(value: &JsonValue) -> &'static str {
|
||||
match value {
|
||||
JsonValue::Null => "null",
|
||||
JsonValue::Bool(_) => "boolean",
|
||||
JsonValue::Number(_) => "number",
|
||||
JsonValue::String(_) => "string",
|
||||
JsonValue::Array(_) => "array",
|
||||
JsonValue::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
// `plugin.json` paths are required to be relative to the plugin root and we return the
|
||||
// normalized absolute path to the rest of the system.
|
||||
let path = path?;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(relative_path) = path.strip_prefix("./") else {
|
||||
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
|
||||
return None;
|
||||
};
|
||||
if relative_path.is_empty() {
|
||||
tracing::warn!("ignoring {field}: path must not be `./`");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = std::path::PathBuf::new();
|
||||
for component in Path::new(relative_path).components() {
|
||||
match component {
|
||||
Component::Normal(component) => normalized.push(component),
|
||||
Component::ParentDir => {
|
||||
tracing::warn!("ignoring {field}: path must not contain '..'");
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: path must stay within the plugin root");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AbsolutePathBuf::try_from(plugin_root.join(normalized))
|
||||
.map_err(|err| {
|
||||
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
|
||||
err
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_DEFAULT_PROMPT_LEN;
|
||||
use super::PluginManifest;
|
||||
use super::load_plugin_manifest;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
|
||||
let version = version
|
||||
.map(|version| format!(" \"version\": \"{version}\",\n"))
|
||||
.unwrap_or_default();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "demo-plugin",
|
||||
{version}
|
||||
"interface": {interface}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
load_plugin_manifest(plugin_root).expect("load plugin manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_accepts_legacy_default_prompt_string() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": " Summarize my inbox "
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec!["Summarize my inbox".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_normalizes_default_prompt_array() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
&format!(
|
||||
r#"{{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": [
|
||||
" Summarize my inbox ",
|
||||
123,
|
||||
"{too_long}",
|
||||
" ",
|
||||
"Draft the reply ",
|
||||
"Find my next action",
|
||||
"Archive old mail"
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Summarize my inbox".to_string(),
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_ignores_invalid_default_prompt_shape() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": { "text": "Summarize my inbox" }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(interface.default_prompt, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_reads_trimmed_version() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
Some(" 1.2.3-beta+7 "),
|
||||
r#"{
|
||||
"displayName": "Demo Plugin"
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
|
||||
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
|
||||
}
|
||||
}
|
||||
552
codex-rs/core-plugins/src/marketplace.rs
Normal file
552
codex-rs/core-plugins/src/marketplace.rs
Normal file
@@ -0,0 +1,552 @@
|
||||
use crate::manifest::PluginManifestInterface;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
|
||||
".agents/plugins/marketplace.json",
|
||||
".claude-plugin/marketplace.json",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedMarketplacePlugin {
|
||||
pub plugin_id: PluginId,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Marketplace {
|
||||
pub name: String,
|
||||
pub path: AbsolutePathBuf,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<MarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceListError {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MarketplaceListOutcome {
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceInterface {
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePlugin {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSource {
|
||||
Local { path: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePluginPolicy {
|
||||
pub installation: MarketplacePluginInstallPolicy,
|
||||
pub authentication: MarketplacePluginAuthPolicy,
|
||||
// TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of
|
||||
// only carrying it through core marketplace metadata.
|
||||
pub products: Option<Vec<Product>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginInstallPolicy {
|
||||
#[serde(rename = "NOT_AVAILABLE")]
|
||||
NotAvailable,
|
||||
#[default]
|
||||
#[serde(rename = "AVAILABLE")]
|
||||
Available,
|
||||
#[serde(rename = "INSTALLED_BY_DEFAULT")]
|
||||
InstalledByDefault,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginAuthPolicy {
|
||||
#[default]
|
||||
#[serde(rename = "ON_INSTALL")]
|
||||
OnInstall,
|
||||
#[serde(rename = "ON_USE")]
|
||||
OnUse,
|
||||
}
|
||||
|
||||
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
|
||||
fn from(value: MarketplacePluginInstallPolicy) -> Self {
|
||||
match value {
|
||||
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
|
||||
MarketplacePluginInstallPolicy::Available => Self::Available,
|
||||
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
|
||||
fn from(value: MarketplacePluginAuthPolicy) -> Self {
|
||||
match value {
|
||||
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
|
||||
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceError {
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("marketplace file `{path}` does not exist")]
|
||||
MarketplaceNotFound { path: PathBuf },
|
||||
|
||||
#[error("invalid marketplace file `{path}`: {message}")]
|
||||
InvalidMarketplaceFile { path: PathBuf, message: String },
|
||||
|
||||
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
|
||||
PluginNotFound {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
|
||||
)]
|
||||
PluginNotAvailable {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error("plugins feature is disabled")]
|
||||
PluginsDisabled,
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
}
|
||||
|
||||
impl MarketplaceError {
|
||||
fn io(context: &'static str, source: io::Error) -> Self {
|
||||
Self::Io { context, source }
|
||||
}
|
||||
}
|
||||
|
||||
// Always read the specified marketplace file from disk so installs see the
|
||||
// latest marketplace.json contents without any in-memory cache invalidation.
|
||||
pub fn resolve_marketplace_plugin(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let marketplace_name_for_not_found = marketplace_name.clone();
|
||||
for plugin in marketplace.plugins {
|
||||
if plugin.name != plugin_name {
|
||||
continue;
|
||||
}
|
||||
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
policy,
|
||||
..
|
||||
} = plugin;
|
||||
let install_policy = policy.installation;
|
||||
let product_allowed = match policy.products.as_deref() {
|
||||
None => true,
|
||||
Some([]) => false,
|
||||
Some(products) => restriction_product
|
||||
.is_some_and(|product| product.matches_product_restriction(products)),
|
||||
};
|
||||
if install_policy == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed {
|
||||
return Err(MarketplaceError::PluginNotAvailable {
|
||||
plugin_name: name,
|
||||
marketplace_name,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(source_path) =
|
||||
resolve_supported_plugin_source_path(marketplace_path, &name, source)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
return Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new(name, marketplace_name).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?,
|
||||
source_path,
|
||||
auth_policy: policy.authentication,
|
||||
});
|
||||
}
|
||||
|
||||
Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name: marketplace_name_for_not_found,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
|
||||
let Some(path) = find_marketplace_manifest_path(root) else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: root.to_path_buf(),
|
||||
message: "marketplace root does not contain a supported manifest".to_string(),
|
||||
});
|
||||
};
|
||||
let marketplace = load_marketplace(&path)?;
|
||||
Ok(marketplace.name)
|
||||
}
|
||||
|
||||
pub fn find_marketplace_manifest_path(root: &Path) -> Option<AbsolutePathBuf> {
|
||||
MARKETPLACE_MANIFEST_RELATIVE_PATHS
|
||||
.iter()
|
||||
.find_map(|relative_path| {
|
||||
let path = root.join(relative_path);
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
AbsolutePathBuf::try_from(path).ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
message: "marketplace file is not in a supported location".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option<PathBuf> {
|
||||
let mut current = marketplace_path;
|
||||
for component in Path::new(relative_path).components().rev() {
|
||||
let expected = match component {
|
||||
Component::Normal(expected) => expected,
|
||||
_ => return None,
|
||||
};
|
||||
if current.file_name() != Some(expected) {
|
||||
return None;
|
||||
}
|
||||
current = current.parent()?;
|
||||
}
|
||||
Some(current.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
|
||||
let marketplace = load_raw_marketplace_manifest(path)?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
policy,
|
||||
category,
|
||||
} = plugin;
|
||||
let Some(source_path) = resolve_supported_plugin_source_path(path, &name, source) else {
|
||||
continue;
|
||||
};
|
||||
let source = MarketplacePluginSource::Local {
|
||||
path: source_path.clone(),
|
||||
};
|
||||
let mut interface =
|
||||
load_plugin_manifest(source_path.as_path()).and_then(|manifest| manifest.interface);
|
||||
if let Some(category) = category {
|
||||
// Marketplace taxonomy wins when both sources provide a category.
|
||||
interface
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
|
||||
plugins.push(MarketplacePlugin {
|
||||
name,
|
||||
source,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: policy.installation,
|
||||
authentication: policy.authentication,
|
||||
products: policy.products,
|
||||
},
|
||||
interface,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Marketplace {
|
||||
name: marketplace.name,
|
||||
path: path.clone(),
|
||||
interface: resolve_marketplace_interface(marketplace.interface),
|
||||
plugins,
|
||||
})
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
let mut outcome = MarketplaceListOutcome::default();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
match load_marketplace(&marketplace_path) {
|
||||
Ok(marketplace) => outcome.marketplaces.push(marketplace),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
error = %err,
|
||||
"skipping marketplace that failed to load"
|
||||
);
|
||||
outcome.errors.push(MarketplaceListError {
|
||||
path: marketplace_path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(home) = home_dir
|
||||
&& let Some(path) = find_marketplace_manifest_path(home)
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
|
||||
for root in additional_roots {
|
||||
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
|
||||
// checkout, so check the root directly before falling back to repo-root discovery.
|
||||
if let Some(path) = find_marketplace_manifest_path(root.as_path())
|
||||
&& !paths.contains(&path)
|
||||
{
|
||||
paths.push(path);
|
||||
continue;
|
||||
}
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path())
|
||||
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
|
||||
&& let Some(path) = find_marketplace_manifest_path(repo_root.as_path())
|
||||
&& !paths.contains(&path)
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn load_raw_marketplace_manifest(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Result<RawMarketplaceManifest, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
MarketplaceError::MarketplaceNotFound {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
MarketplaceError::io("failed to read marketplace file", err)
|
||||
}
|
||||
})?;
|
||||
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_supported_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
match source {
|
||||
RawMarketplaceManifestPluginSource::Local { path } => {
|
||||
match resolve_local_plugin_source_path(marketplace_path, &path) {
|
||||
Ok(path) => Some(path),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
plugin = plugin_name,
|
||||
error = %err,
|
||||
"skipping marketplace plugin that failed to resolve"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
RawMarketplaceManifestPluginSource::Unsupported => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
plugin = plugin_name,
|
||||
"skipping marketplace plugin with unsupported source"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_local_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source_path: &str,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
let Some(source_path) = source_path.strip_prefix("./") else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must start with `./`".to_string(),
|
||||
});
|
||||
};
|
||||
if source_path.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let relative_source_path = Path::new(source_path);
|
||||
if relative_source_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must stay within the marketplace root".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// `marketplace.json` lives under a supported marketplace layout beneath `<root>`,
|
||||
// but local plugin paths are resolved relative to `<root>`.
|
||||
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
|
||||
}
|
||||
|
||||
fn marketplace_root_dir(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {
|
||||
if let Some(marketplace_root) =
|
||||
marketplace_root_from_layout(marketplace_path.as_path(), relative_path)
|
||||
{
|
||||
return AbsolutePathBuf::try_from(marketplace_root)
|
||||
.map_err(|_| invalid_marketplace_layout_error(marketplace_path));
|
||||
}
|
||||
}
|
||||
|
||||
Err(invalid_marketplace_layout_error(marketplace_path))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
plugins: Vec<RawMarketplaceManifestPlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestPlugin {
|
||||
name: String,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
#[serde(default)]
|
||||
policy: RawMarketplaceManifestPluginPolicy,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestPluginPolicy {
|
||||
#[serde(default)]
|
||||
installation: MarketplacePluginInstallPolicy,
|
||||
#[serde(default)]
|
||||
authentication: MarketplacePluginAuthPolicy,
|
||||
products: Option<Vec<Product>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RawMarketplaceManifestPluginSource {
|
||||
Local { path: String },
|
||||
// Mixed-source marketplaces should still contribute the local plugins we can load.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for RawMarketplaceManifestPluginSource {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let source = JsonValue::deserialize(deserializer)?;
|
||||
Ok(match source {
|
||||
JsonValue::String(path) => Self::Local { path },
|
||||
JsonValue::Object(object) => match object.get("source").and_then(JsonValue::as_str) {
|
||||
Some("local") => match object.get("path").and_then(JsonValue::as_str) {
|
||||
Some(path) => Self::Local {
|
||||
path: path.to_string(),
|
||||
},
|
||||
None => Self::Unsupported,
|
||||
},
|
||||
_ => Self::Unsupported,
|
||||
},
|
||||
_ => Self::Unsupported,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_marketplace_interface(
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
) -> Option<MarketplaceInterface> {
|
||||
let interface = interface?;
|
||||
if interface.display_name.is_some() {
|
||||
Some(MarketplaceInterface {
|
||||
display_name: interface.display_name,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "marketplace_tests.rs"]
|
||||
mod tests;
|
||||
1161
codex-rs/core-plugins/src/marketplace_tests.rs
Normal file
1161
codex-rs/core-plugins/src/marketplace_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
317
codex-rs/core-plugins/src/remote.rs
Normal file
317
codex-rs/core-plugins/src/remote.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginServiceConfig {
|
||||
pub chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct RemotePluginStatusSummary {
|
||||
pub name: String,
|
||||
#[serde(default = "default_remote_marketplace_name")]
|
||||
pub marketplace_name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemotePluginMutationResponse {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginMutationError {
|
||||
#[error("chatgpt authentication required for remote plugin mutation")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin mutation: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("chatgpt base url cannot be used for plugin mutation")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("failed to send remote plugin mutation request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin mutation response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
UnexpectedEnabledState {
|
||||
plugin_id: String,
|
||||
expected_enabled: bool,
|
||||
actual_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginFetchError {
|
||||
#[error("chatgpt authentication required to sync remote plugins")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin sync: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("failed to send remote plugin sync request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin sync response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_plugin_status(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginFetchError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginFetchError::UnsupportedAuthMode);
|
||||
}
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/list");
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginFetchError::AuthToken)?;
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginFetchError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_featured_plugin_ids(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
product: Option<Product>,
|
||||
) -> Result<Vec<String>, RemotePluginFetchError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/featured");
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.query(&[(
|
||||
"platform",
|
||||
product.unwrap_or(Product::Codex).to_app_platform(),
|
||||
)])
|
||||
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
|
||||
|
||||
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginFetchError::AuthToken)?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginFetchError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn enable_remote_plugin(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall_remote_plugin(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginMutationError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginMutationError::UnsupportedAuthMode);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
fn default_remote_marketplace_name() -> String {
|
||||
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
|
||||
}
|
||||
|
||||
async fn post_remote_plugin_mutation(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginMutationError::AuthToken)?;
|
||||
let mut request = client
|
||||
.post(url.clone())
|
||||
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginMutationError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
let parsed: RemotePluginMutationResponse =
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let expected_enabled = action == "enable";
|
||||
if parsed.id != plugin_id {
|
||||
return Err(RemotePluginMutationError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: parsed.id,
|
||||
});
|
||||
}
|
||||
if parsed.enabled != expected_enabled {
|
||||
return Err(RemotePluginMutationError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled,
|
||||
actual_enabled: parsed.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn remote_plugin_mutation_url(
|
||||
config: &RemotePluginServiceConfig,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, RemotePluginMutationError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push(action);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
368
codex-rs/core-plugins/src/store.rs
Normal file
368
codex-rs/core-plugins/src/store.rs
Normal file
@@ -0,0 +1,368 @@
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_version: String,
|
||||
pub installed_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginStore {
|
||||
root: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl PluginStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR))
|
||||
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &AbsolutePathBuf {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.root
|
||||
.as_path()
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.plugin_base_root(plugin_id)
|
||||
.as_path()
|
||||
.join(plugin_version),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
|
||||
entry.file_name().into_string().ok()
|
||||
})
|
||||
.filter(|version| validate_plugin_version_segment(version).is_ok())
|
||||
.collect::<Vec<_>>();
|
||||
discovered_versions.sort_unstable();
|
||||
if discovered_versions.is_empty() {
|
||||
None
|
||||
} else if discovered_versions
|
||||
.iter()
|
||||
.any(|version| version == DEFAULT_PLUGIN_VERSION)
|
||||
{
|
||||
Some(DEFAULT_PLUGIN_VERSION.to_string())
|
||||
} else {
|
||||
discovered_versions.pop()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
|
||||
self.active_plugin_version(plugin_id)
|
||||
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
|
||||
self.active_plugin_version(plugin_id).is_some()
|
||||
}
|
||||
|
||||
pub fn install(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
let plugin_version = plugin_version_for_source(source_path.as_path())?;
|
||||
self.install_with_version(source_path, plugin_id, plugin_version)
|
||||
}
|
||||
|
||||
pub fn install_with_version(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
plugin_version: String,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source path is not a directory: {}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let plugin_name = plugin_name_for_source(source_path.as_path())?;
|
||||
if plugin_name != plugin_id.plugin_name {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||
plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
||||
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
|
||||
replace_plugin_root_atomically(
|
||||
source_path.as_path(),
|
||||
self.plugin_base_root(&plugin_id).as_path(),
|
||||
&plugin_version,
|
||||
)?;
|
||||
|
||||
Ok(PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
installed_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
|
||||
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginStoreError {
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl PluginStoreError {
|
||||
fn io(context: &'static str, source: io::Error) -> Self {
|
||||
Self::Io { context, source }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||
let plugin_version = plugin_manifest_version_for_source(source_path)?
|
||||
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
|
||||
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
||||
Ok(plugin_version)
|
||||
}
|
||||
|
||||
fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {
|
||||
if plugin_version.is_empty() {
|
||||
return Err("invalid plugin version: must not be empty".to_string());
|
||||
}
|
||||
if matches!(plugin_version, "." | "..") {
|
||||
return Err("invalid plugin version: path traversal is not allowed".to_string());
|
||||
}
|
||||
if !plugin_version
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
|
||||
{
|
||||
return Err(
|
||||
"invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
|
||||
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"missing plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
load_plugin_manifest(source_path).ok_or_else(|| {
|
||||
PluginStoreError::Invalid(format!(
|
||||
"missing or invalid plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestVersion {
|
||||
#[serde(default)]
|
||||
version: Option<JsonValue>,
|
||||
}
|
||||
|
||||
fn plugin_manifest_version_for_source(
|
||||
source_path: &Path,
|
||||
) -> Result<Option<String>, PluginStoreError> {
|
||||
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"missing plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&manifest_path)
|
||||
.map_err(|err| PluginStoreError::io("failed to read plugin manifest", err))?;
|
||||
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents).map_err(|err| {
|
||||
PluginStoreError::Invalid(format!(
|
||||
"failed to parse plugin manifest {}: {err}",
|
||||
manifest_path.display()
|
||||
))
|
||||
})?;
|
||||
let Some(version) = manifest.version else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(version) = version.as_str() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"invalid plugin version in manifest {}: expected string",
|
||||
manifest_path.display()
|
||||
)));
|
||||
};
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"invalid plugin version in manifest {}: must not be blank",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(Some(version.to_string()))
|
||||
}
|
||||
|
||||
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||
let manifest = plugin_manifest_for_source(source_path)?;
|
||||
|
||||
let plugin_name = manifest.name;
|
||||
validate_plugin_segment(&plugin_name, "plugin name")
|
||||
.map_err(PluginStoreError::Invalid)
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path).map_err(|err| {
|
||||
PluginStoreError::io("failed to remove existing plugin cache entry", err)
|
||||
})
|
||||
} else {
|
||||
fs::remove_file(path).map_err(|err| {
|
||||
PluginStoreError::io("failed to remove existing plugin cache entry", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_plugin_root_atomically(
|
||||
source: &Path,
|
||||
target_root: &Path,
|
||||
plugin_version: &str,
|
||||
) -> Result<(), PluginStoreError> {
|
||||
let Some(parent) = target_root.parent() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no parent: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
|
||||
|
||||
let Some(plugin_dir_name) = target_root.file_name() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no directory name: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
let staged_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-install-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create temporary plugin cache directory", err)
|
||||
})?;
|
||||
let staged_root = staged_dir.path().join(plugin_dir_name);
|
||||
let staged_version_root = staged_root.join(plugin_version);
|
||||
copy_dir_recursive(source, &staged_version_root)?;
|
||||
|
||||
if target_root.exists() {
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create plugin cache backup directory", err)
|
||||
})?;
|
||||
let backup_root = backup_dir.path().join(plugin_dir_name);
|
||||
fs::rename(target_root, &backup_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;
|
||||
|
||||
if let Err(err) = fs::rename(&staged_root, target_root) {
|
||||
let rollback_result = fs::rename(&backup_root, target_root);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(PluginStoreError::io(
|
||||
"failed to activate updated plugin cache entry",
|
||||
err,
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join(plugin_dir_name);
|
||||
Err(PluginStoreError::Invalid(format!(
|
||||
"failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",
|
||||
target_root.display(),
|
||||
backup_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fs::rename(&staged_root, target_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
|
||||
|
||||
for entry in fs::read_dir(source)
|
||||
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
|
||||
{
|
||||
let entry =
|
||||
entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?;
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
copy_dir_recursive(&source_path, &target_path)?;
|
||||
} else if file_type.is_file() {
|
||||
fs::copy(&source_path, &target_path)
|
||||
.map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_tests.rs"]
|
||||
mod tests;
|
||||
310
codex-rs/core-plugins/src/store_tests.rs
Normal file
310
codex-rs/core-plugins/src/store_tests.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use super::*;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_plugin_with_version(
|
||||
root: &Path,
|
||||
dir_name: &str,
|
||||
manifest_name: &str,
|
||||
manifest_version: Option<&str>,
|
||||
) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
||||
let version = manifest_version
|
||||
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
|
||||
.unwrap_or_default();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
|
||||
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
||||
}
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
write_plugin_with_version(
|
||||
root,
|
||||
dir_name,
|
||||
manifest_name,
|
||||
/*manifest_version*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_copies_plugin_into_default_marketplace() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
assert!(installed_path.join("skills/SKILL.md").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_uses_manifest_name_for_destination_and_key() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(
|
||||
tmp.path().join("plugins/cache/market/manifest-name/local"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_derives_path_from_key_and_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.plugin_root(&plugin_id, "local").as_path(),
|
||||
tmp.path().join("plugins/cache/debug/sample/local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_with_version_uses_requested_cache_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id =
|
||||
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
|
||||
let plugin_version = "0123456789abcdef".to_string();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install_with_version(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
plugin_version.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join(format!(
|
||||
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
|
||||
));
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_uses_manifest_version_when_present() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin_with_version(
|
||||
tmp.path(),
|
||||
"sample-plugin",
|
||||
"sample-plugin",
|
||||
Some("1.2.3-beta+7"),
|
||||
);
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "1.2.3-beta+7".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_blank_manifest_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id,
|
||||
)
|
||||
.expect_err("blank manifest version should be rejected");
|
||||
let err = err.to_string().replace('\\', "/");
|
||||
|
||||
assert!(
|
||||
err.starts_with("invalid plugin version in manifest "),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.ends_with("sample-plugin/.codex-plugin/plugin.json: must not be blank"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_reads_version_directory_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.active_plugin_root(&plugin_id).unwrap().as_path(),
|
||||
tmp.path().join("plugins/cache/debug/sample-plugin/local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/fedcba9876543210",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("fedcba9876543210".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_rejects_path_separators_in_key_segments() {
|
||||
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`"
|
||||
);
|
||||
|
||||
let err = PluginId::parse("sample@../../etc").unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_manifest_names_with_path_separators() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "../../etc");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_marketplace_names_with_path_separators() {
|
||||
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
|
||||
);
|
||||
}
|
||||
100
codex-rs/core-plugins/src/toggles.rs
Normal file
100
codex-rs/core-plugins/src/toggles.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn collect_plugin_enabled_candidates<'a>(
|
||||
edits: impl Iterator<Item = (&'a String, &'a JsonValue)>,
|
||||
) -> BTreeMap<String, bool> {
|
||||
let mut pending_changes = BTreeMap::new();
|
||||
for (key_path, value) in edits {
|
||||
let segments = key_path
|
||||
.split('.')
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
match segments.as_slice() {
|
||||
[plugins, plugin_id, enabled]
|
||||
if plugins == "plugins" && enabled == "enabled" && value.is_boolean() =>
|
||||
{
|
||||
if let Some(enabled) = value.as_bool() {
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
[plugins, plugin_id] if plugins == "plugins" => {
|
||||
if let Some(enabled) = value.get("enabled").and_then(JsonValue::as_bool) {
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
[plugins] if plugins == "plugins" => {
|
||||
let Some(entries) = value.as_object() else {
|
||||
continue;
|
||||
};
|
||||
for (plugin_id, plugin_value) in entries {
|
||||
let Some(enabled) = plugin_value.get("enabled").and_then(JsonValue::as_bool)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pending_changes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_plugin_enabled_candidates;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn collect_plugin_enabled_candidates_tracks_direct_and_table_writes() {
|
||||
let candidates = collect_plugin_enabled_candidates(
|
||||
[
|
||||
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
|
||||
(
|
||||
&"plugins.other@test".to_string(),
|
||||
&json!({ "enabled": false, "ignored": true }),
|
||||
),
|
||||
(
|
||||
&"plugins".to_string(),
|
||||
&json!({
|
||||
"nested@test": { "enabled": true },
|
||||
"skip@test": { "name": "skip" },
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
BTreeMap::from([
|
||||
("nested@test".to_string(), true),
|
||||
("other@test".to_string(), false),
|
||||
("sample@test".to_string(), true),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_plugin_enabled_candidates_uses_last_write_for_same_plugin() {
|
||||
let candidates = collect_plugin_enabled_candidates(
|
||||
[
|
||||
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
|
||||
(
|
||||
&"plugins.sample@test".to_string(),
|
||||
&json!({ "enabled": false }),
|
||||
),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
BTreeMap::from([("sample@test".to_string(), false)])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user