feat: Add product-aware plugin policies and clean up manifest naming (#14993)
- Add shared Product support to marketplace plugin policy and skill policy (no enforced yet). - Move marketplace installation/authentication under policy and model it as MarketplacePluginPolicy. - Rename plugin/marketplace local manifest types to separate raw serde shapes from resolved in-memory models.
This commit is contained in:
parent
fc75d07504
commit
a5d3114e97
15 changed files with 511 additions and 314 deletions
|
|
@ -220,7 +220,7 @@ use codex_core::mcp::group_tools_by_server;
|
|||
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_core::parse_cursor;
|
||||
use codex_core::plugins::MarketplaceError;
|
||||
use codex_core::plugins::MarketplacePluginSourceSummary;
|
||||
use codex_core::plugins::MarketplacePluginSource;
|
||||
use codex_core::plugins::PluginInstallError as CorePluginInstallError;
|
||||
use codex_core::plugins::PluginInstallRequest;
|
||||
use codex_core::plugins::PluginReadRequest;
|
||||
|
|
@ -5429,8 +5429,8 @@ impl CodexMessageProcessor {
|
|||
enabled: plugin.enabled,
|
||||
name: plugin.name,
|
||||
source: marketplace_plugin_source_to_info(plugin.source),
|
||||
install_policy: plugin.install_policy.into(),
|
||||
auth_policy: plugin.auth_policy.into(),
|
||||
install_policy: plugin.policy.installation.into(),
|
||||
auth_policy: plugin.policy.authentication.into(),
|
||||
interface: plugin.interface.map(plugin_interface_to_info),
|
||||
})
|
||||
.collect(),
|
||||
|
|
@ -5519,8 +5519,8 @@ impl CodexMessageProcessor {
|
|||
source: marketplace_plugin_source_to_info(outcome.plugin.source),
|
||||
installed: outcome.plugin.installed,
|
||||
enabled: outcome.plugin.enabled,
|
||||
install_policy: outcome.plugin.install_policy.into(),
|
||||
auth_policy: outcome.plugin.auth_policy.into(),
|
||||
install_policy: outcome.plugin.policy.installation.into(),
|
||||
auth_policy: outcome.plugin.policy.authentication.into(),
|
||||
interface: outcome.plugin.interface.map(plugin_interface_to_info),
|
||||
},
|
||||
description: outcome.plugin.description,
|
||||
|
|
@ -7456,7 +7456,7 @@ fn plugin_skills_to_info(skills: &[codex_core::skills::SkillMetadata]) -> Vec<Sk
|
|||
}
|
||||
|
||||
fn plugin_interface_to_info(
|
||||
interface: codex_core::plugins::PluginManifestInterfaceSummary,
|
||||
interface: codex_core::plugins::PluginManifestInterface,
|
||||
) -> PluginInterface {
|
||||
PluginInterface {
|
||||
display_name: interface.display_name,
|
||||
|
|
@ -7476,9 +7476,9 @@ fn plugin_interface_to_info(
|
|||
}
|
||||
}
|
||||
|
||||
fn marketplace_plugin_source_to_info(source: MarketplacePluginSourceSummary) -> PluginSource {
|
||||
fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource {
|
||||
match source {
|
||||
MarketplacePluginSourceSummary::Local { path } => PluginSource::Local { path },
|
||||
MarketplacePluginSource::Local { path } => PluginSource::Local { path },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -655,12 +655,24 @@ fn write_plugin_marketplace(
|
|||
install_policy: Option<&str>,
|
||||
auth_policy: Option<&str>,
|
||||
) -> std::io::Result<()> {
|
||||
let install_policy = install_policy
|
||||
.map(|install_policy| format!(",\n \"installPolicy\": \"{install_policy}\""))
|
||||
.unwrap_or_default();
|
||||
let auth_policy = auth_policy
|
||||
.map(|auth_policy| format!(",\n \"authPolicy\": \"{auth_policy}\""))
|
||||
.unwrap_or_default();
|
||||
let policy = if install_policy.is_some() || auth_policy.is_some() {
|
||||
let installation = install_policy
|
||||
.map(|installation| format!("\n \"installation\": \"{installation}\""))
|
||||
.unwrap_or_default();
|
||||
let separator = if install_policy.is_some() && auth_policy.is_some() {
|
||||
","
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let authentication = auth_policy
|
||||
.map(|authentication| {
|
||||
format!("{separator}\n \"authentication\": \"{authentication}\"")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(",\n \"policy\": {{{installation}{authentication}\n }}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
std::fs::create_dir_all(repo_root.join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
|
|
@ -674,7 +686,7 @@ fn write_plugin_marketplace(
|
|||
"source": {{
|
||||
"source": "local",
|
||||
"path": "{source_path}"
|
||||
}}{install_policy}{auth_policy}
|
||||
}}{policy}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
|
|
|
|||
|
|
@ -396,8 +396,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
|||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Design"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
|
|||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Design"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
use super::PluginManifestPaths;
|
||||
use super::curated_plugins_repo_path;
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PluginManifestInterfaceSummary;
|
||||
use super::manifest::PluginManifestInterface;
|
||||
use super::marketplace::MarketplaceError;
|
||||
use super::marketplace::MarketplaceInterfaceSummary;
|
||||
use super::marketplace::MarketplaceInterface;
|
||||
use super::marketplace::MarketplacePluginAuthPolicy;
|
||||
use super::marketplace::MarketplacePluginInstallPolicy;
|
||||
use super::marketplace::MarketplacePluginSourceSummary;
|
||||
use super::marketplace::MarketplacePluginPolicy;
|
||||
use super::marketplace::MarketplacePluginSource;
|
||||
use super::marketplace::ResolvedMarketplacePlugin;
|
||||
use super::marketplace::list_marketplaces;
|
||||
use super::marketplace::load_marketplace_summary;
|
||||
use super::marketplace::load_marketplace;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::plugin_manifest_paths;
|
||||
use super::read_curated_plugins_sha;
|
||||
use super::remote::RemotePluginFetchError;
|
||||
use super::remote::RemotePluginMutationError;
|
||||
|
|
@ -99,18 +97,17 @@ pub struct PluginInstallOutcome {
|
|||
pub struct PluginReadOutcome {
|
||||
pub marketplace_name: String,
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
pub plugin: PluginDetailSummary,
|
||||
pub plugin: PluginDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginDetailSummary {
|
||||
pub struct PluginDetail {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub install_policy: MarketplacePluginInstallPolicy,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
|
|
@ -119,21 +116,20 @@ pub struct PluginDetailSummary {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplaceSummary {
|
||||
pub struct ConfiguredMarketplace {
|
||||
pub name: String,
|
||||
pub path: AbsolutePathBuf,
|
||||
pub interface: Option<MarketplaceInterfaceSummary>,
|
||||
pub plugins: Vec<ConfiguredMarketplacePluginSummary>,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<ConfiguredMarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfiguredMarketplacePluginSummary {
|
||||
pub struct ConfiguredMarketplacePlugin {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub install_policy: MarketplacePluginInstallPolicy,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
|
@ -219,8 +215,8 @@ impl PluginCapabilitySummary {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<PluginDetailSummary> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetailSummary) -> Self {
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
Self {
|
||||
config_name: value.id,
|
||||
display_name: value.name,
|
||||
|
|
@ -648,7 +644,7 @@ impl PluginsManager {
|
|||
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?;
|
||||
let curated_marketplace = match load_marketplace_summary(&curated_marketplace_path) {
|
||||
let curated_marketplace = match load_marketplace(&curated_marketplace_path) {
|
||||
Ok(marketplace) => marketplace,
|
||||
Err(MarketplaceError::MarketplaceNotFound { .. }) => {
|
||||
return Err(PluginRemoteSyncError::LocalMarketplaceNotFound);
|
||||
|
|
@ -685,7 +681,7 @@ impl PluginsManager {
|
|||
let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSourceSummary::Local { path } => path,
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
};
|
||||
let current_enabled = configured_plugins
|
||||
.get(&plugin_key)
|
||||
|
|
@ -820,7 +816,7 @@ impl PluginsManager {
|
|||
&self,
|
||||
config: &Config,
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<ConfiguredMarketplaceSummary>, MarketplaceError> {
|
||||
) -> Result<Vec<ConfiguredMarketplace>, MarketplaceError> {
|
||||
let (installed_plugins, configured_plugins) = self.configured_plugin_states(config);
|
||||
let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?;
|
||||
let mut seen_plugin_keys = HashSet::new();
|
||||
|
|
@ -838,7 +834,7 @@ impl PluginsManager {
|
|||
return None;
|
||||
}
|
||||
|
||||
Some(ConfiguredMarketplacePluginSummary {
|
||||
Some(ConfiguredMarketplacePlugin {
|
||||
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
|
||||
// plugin entries from duplicate marketplace files intentionally
|
||||
// resolve to the first discovered source.
|
||||
|
|
@ -850,14 +846,13 @@ impl PluginsManager {
|
|||
.unwrap_or(false),
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
install_policy: plugin.install_policy,
|
||||
auth_policy: plugin.auth_policy,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(!plugins.is_empty()).then_some(ConfiguredMarketplaceSummary {
|
||||
(!plugins.is_empty()).then_some(ConfiguredMarketplace {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
interface: marketplace.interface,
|
||||
|
|
@ -872,7 +867,7 @@ impl PluginsManager {
|
|||
config: &Config,
|
||||
request: &PluginReadRequest,
|
||||
) -> Result<PluginReadOutcome, MarketplaceError> {
|
||||
let marketplace = load_marketplace_summary(&request.marketplace_path)?;
|
||||
let marketplace = load_marketplace(&request.marketplace_path)?;
|
||||
let marketplace_name = marketplace.name.clone();
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
|
|
@ -893,7 +888,7 @@ impl PluginsManager {
|
|||
let plugin_key = plugin_id.as_key();
|
||||
let (installed_plugins, configured_plugins) = self.configured_plugin_states(config);
|
||||
let source_path = match &plugin.source {
|
||||
MarketplacePluginSourceSummary::Local { path } => path.clone(),
|
||||
MarketplacePluginSource::Local { path } => path.clone(),
|
||||
};
|
||||
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(
|
||||
|
|
@ -901,15 +896,15 @@ impl PluginsManager {
|
|||
)
|
||||
})?;
|
||||
let description = manifest.description.clone();
|
||||
let manifest_paths = plugin_manifest_paths(&manifest, source_path.as_path());
|
||||
let skill_roots = plugin_skill_roots(source_path.as_path(), &manifest_paths);
|
||||
let manifest_paths = &manifest.paths;
|
||||
let skill_roots = plugin_skill_roots(source_path.as_path(), manifest_paths);
|
||||
let skills = load_skills_from_roots(skill_roots.into_iter().map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}))
|
||||
.skills;
|
||||
let apps = load_plugin_apps(source_path.as_path());
|
||||
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), &manifest_paths);
|
||||
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths);
|
||||
let mut mcp_server_names = Vec::new();
|
||||
for mcp_config_path in mcp_config_paths {
|
||||
mcp_server_names.extend(
|
||||
|
|
@ -924,13 +919,12 @@ impl PluginsManager {
|
|||
Ok(PluginReadOutcome {
|
||||
marketplace_name: marketplace.name,
|
||||
marketplace_path: marketplace.path,
|
||||
plugin: PluginDetailSummary {
|
||||
plugin: PluginDetail {
|
||||
id: plugin_key.clone(),
|
||||
name: plugin.name,
|
||||
description,
|
||||
source: plugin.source,
|
||||
install_policy: plugin.install_policy,
|
||||
auth_policy: plugin.auth_policy,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
installed: installed_plugins.contains(&plugin_key),
|
||||
enabled: configured_plugins
|
||||
|
|
@ -1200,7 +1194,7 @@ pub(crate) fn load_plugins_from_layer_stack(
|
|||
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(manifest) = load_plugin_manifest(ancestor) {
|
||||
return Some(plugin_manifest_name(&manifest, ancestor));
|
||||
return Some(manifest.name);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1217,7 +1211,7 @@ fn refresh_curated_plugin_cache(
|
|||
curated_plugins_repo_path(codex_home).join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| "local curated marketplace is not available".to_string())?;
|
||||
let curated_marketplace = load_marketplace_summary(&curated_marketplace_path)
|
||||
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();
|
||||
|
|
@ -1232,7 +1226,7 @@ fn refresh_curated_plugin_cache(
|
|||
continue;
|
||||
}
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSourceSummary::Local { path } => path,
|
||||
MarketplacePluginSource::Local { path } => path,
|
||||
};
|
||||
plugin_sources.insert(plugin_name, source_path);
|
||||
}
|
||||
|
|
@ -1329,12 +1323,12 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore)
|
|||
return loaded_plugin;
|
||||
};
|
||||
|
||||
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root.as_path());
|
||||
loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path()));
|
||||
loaded_plugin.manifest_description = manifest.description;
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), &manifest_paths);
|
||||
let manifest_paths = &manifest.paths;
|
||||
loaded_plugin.manifest_name = Some(manifest.name.clone());
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(plugin_root.as_path(), manifest_paths);
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root.as_path(), &manifest_paths) {
|
||||
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);
|
||||
for (name, config) in plugin_mcp.mcp_servers {
|
||||
if mcp_servers.insert(name.clone(), config).is_some() {
|
||||
|
|
@ -1396,10 +1390,9 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
|||
|
||||
pub fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root);
|
||||
return load_apps_from_paths(
|
||||
plugin_root,
|
||||
plugin_app_config_paths(plugin_root, &manifest_paths),
|
||||
plugin_app_config_paths(plugin_root, &manifest.paths),
|
||||
);
|
||||
}
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root))
|
||||
|
|
@ -1475,10 +1468,10 @@ pub fn plugin_telemetry_metadata_from_root(
|
|||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
|
||||
let manifest_paths = plugin_manifest_paths(&manifest, plugin_root);
|
||||
let has_skills = !plugin_skill_roots(plugin_root, &manifest_paths).is_empty();
|
||||
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, &manifest_paths) {
|
||||
for path in plugin_mcp_config_paths(plugin_root, manifest_paths) {
|
||||
mcp_server_names.extend(
|
||||
load_mcp_servers_from_file(plugin_root, &path)
|
||||
.mcp_servers
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use crate::config_loader::ConfigLayerEntry;
|
|||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
|
|
@ -811,7 +812,9 @@ async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
|
|||
"source": "local",
|
||||
"path": "./sample-plugin"
|
||||
},
|
||||
"authPolicy": "ON_USE"
|
||||
"policy": {
|
||||
"authentication": "ON_USE"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
|
|
@ -952,7 +955,7 @@ enabled = false
|
|||
|
||||
assert_eq!(
|
||||
marketplace,
|
||||
ConfiguredMarketplaceSummary {
|
||||
ConfiguredMarketplace {
|
||||
name: "debug".to_string(),
|
||||
path: AbsolutePathBuf::try_from(
|
||||
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
||||
|
|
@ -960,28 +963,34 @@ enabled = false
|
|||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
ConfiguredMarketplacePlugin {
|
||||
id: "enabled-plugin@debug".to_string(),
|
||||
name: "enabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
},
|
||||
ConfiguredMarketplacePluginSummary {
|
||||
ConfiguredMarketplacePlugin {
|
||||
id: "disabled-plugin@debug".to_string(),
|
||||
name: "disabled-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: true,
|
||||
enabled: false,
|
||||
|
|
@ -1036,21 +1045,24 @@ async fn list_marketplaces_includes_curated_repo_marketplace() {
|
|||
|
||||
assert_eq!(
|
||||
curated_marketplace,
|
||||
ConfiguredMarketplaceSummary {
|
||||
ConfiguredMarketplace {
|
||||
name: "openai-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
interface: Some(MarketplaceInterfaceSummary {
|
||||
interface: Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Official".to_string()),
|
||||
}),
|
||||
plugins: vec![ConfiguredMarketplacePluginSummary {
|
||||
plugins: vec![ConfiguredMarketplacePlugin {
|
||||
id: "linear@openai-curated".to_string(),
|
||||
name: "linear".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
|
|
@ -1143,14 +1155,17 @@ enabled = false
|
|||
.expect("repo-a marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_a_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
vec![ConfiguredMarketplacePlugin {
|
||||
id: "dup-plugin@debug".to_string(),
|
||||
name: "dup-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: true,
|
||||
|
|
@ -1169,14 +1184,17 @@ enabled = false
|
|||
.expect("repo-b marketplace should be listed");
|
||||
assert_eq!(
|
||||
repo_b_marketplace.plugins,
|
||||
vec![ConfiguredMarketplacePluginSummary {
|
||||
vec![ConfiguredMarketplacePlugin {
|
||||
id: "b-only-plugin@debug".to_string(),
|
||||
name: "b-only-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
|
|
@ -1241,21 +1259,24 @@ enabled = true
|
|||
|
||||
assert_eq!(
|
||||
marketplace,
|
||||
ConfiguredMarketplaceSummary {
|
||||
ConfiguredMarketplace {
|
||||
name: "debug".to_string(),
|
||||
path: AbsolutePathBuf::try_from(
|
||||
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![ConfiguredMarketplacePluginSummary {
|
||||
plugins: vec![ConfiguredMarketplacePlugin {
|
||||
id: "sample-plugin@debug".to_string(),
|
||||
name: "sample-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: true,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@ const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
|||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PluginManifest {
|
||||
struct RawPluginManifest {
|
||||
#[serde(default)]
|
||||
pub(crate) name: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
pub(crate) description: Option<String>,
|
||||
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)]
|
||||
|
|
@ -25,7 +25,15 @@ pub(crate) struct PluginManifest {
|
|||
#[serde(default)]
|
||||
apps: Option<String>,
|
||||
#[serde(default)]
|
||||
interface: Option<PluginManifestInterface>,
|
||||
interface: Option<RawPluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PluginManifest {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) paths: PluginManifestPaths,
|
||||
pub(crate) interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -36,7 +44,7 @@ pub struct PluginManifestPaths {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginManifestInterfaceSummary {
|
||||
pub struct PluginManifestInterface {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub long_description: Option<String>,
|
||||
|
|
@ -55,7 +63,7 @@ pub struct PluginManifestInterfaceSummary {
|
|||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PluginManifestInterface {
|
||||
struct RawPluginManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
@ -78,7 +86,7 @@ struct PluginManifestInterface {
|
|||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<PluginManifestDefaultPrompt>,
|
||||
default_prompt: Option<RawPluginManifestDefaultPrompt>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
|
|
@ -91,15 +99,15 @@ struct PluginManifestInterface {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPrompt {
|
||||
enum RawPluginManifestDefaultPrompt {
|
||||
String(String),
|
||||
List(Vec<PluginManifestDefaultPromptEntry>),
|
||||
List(Vec<RawPluginManifestDefaultPromptEntry>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PluginManifestDefaultPromptEntry {
|
||||
enum RawPluginManifestDefaultPromptEntry {
|
||||
String(String),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
|
@ -110,8 +118,106 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
|
|||
return None;
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
match serde_json::from_str(&contents) {
|
||||
Ok(manifest) => Some(manifest),
|
||||
match serde_json::from_str::<RawPluginManifest>(&contents) {
|
||||
Ok(manifest) => {
|
||||
let RawPluginManifest {
|
||||
name: raw_name,
|
||||
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 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,
|
||||
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(),
|
||||
|
|
@ -122,84 +228,6 @@ pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest>
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path) -> String {
|
||||
plugin_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|_| manifest.name.trim().is_empty())
|
||||
.unwrap_or(&manifest.name)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_manifest_interface(
|
||||
manifest: &PluginManifest,
|
||||
plugin_root: &Path,
|
||||
) -> Option<PluginManifestInterfaceSummary> {
|
||||
let interface = manifest.interface.as_ref()?;
|
||||
let interface = PluginManifestInterfaceSummary {
|
||||
display_name: interface.display_name.clone(),
|
||||
short_description: interface.short_description.clone(),
|
||||
long_description: interface.long_description.clone(),
|
||||
developer_name: interface.developer_name.clone(),
|
||||
category: interface.category.clone(),
|
||||
capabilities: interface.capabilities.clone(),
|
||||
website_url: interface.website_url.clone(),
|
||||
privacy_policy_url: interface.privacy_policy_url.clone(),
|
||||
terms_of_service_url: interface.terms_of_service_url.clone(),
|
||||
default_prompt: resolve_default_prompts(plugin_root, interface.default_prompt.as_ref()),
|
||||
brand_color: interface.brand_color.clone(),
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.composerIcon",
|
||||
interface.composer_icon.as_deref(),
|
||||
),
|
||||
logo: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.logo",
|
||||
interface.logo.as_deref(),
|
||||
),
|
||||
screenshots: interface
|
||||
.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)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_manifest_paths(
|
||||
manifest: &PluginManifest,
|
||||
plugin_root: &Path,
|
||||
) -> PluginManifestPaths {
|
||||
PluginManifestPaths {
|
||||
skills: resolve_manifest_path(plugin_root, "skills", manifest.skills.as_deref()),
|
||||
mcp_servers: resolve_manifest_path(
|
||||
plugin_root,
|
||||
"mcpServers",
|
||||
manifest.mcp_servers.as_deref(),
|
||||
),
|
||||
apps: resolve_manifest_path(plugin_root, "apps", manifest.apps.as_deref()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interface_asset_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
|
|
@ -210,14 +238,14 @@ fn resolve_interface_asset_path(
|
|||
|
||||
fn resolve_default_prompts(
|
||||
plugin_root: &Path,
|
||||
value: Option<&PluginManifestDefaultPrompt>,
|
||||
value: Option<&RawPluginManifestDefaultPrompt>,
|
||||
) -> Option<Vec<String>> {
|
||||
match value? {
|
||||
PluginManifestDefaultPrompt::String(prompt) => {
|
||||
RawPluginManifestDefaultPrompt::String(prompt) => {
|
||||
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
|
||||
.map(|prompt| vec![prompt])
|
||||
}
|
||||
PluginManifestDefaultPrompt::List(values) => {
|
||||
RawPluginManifestDefaultPrompt::List(values) => {
|
||||
let mut prompts = Vec::new();
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
|
||||
|
|
@ -230,7 +258,7 @@ fn resolve_default_prompts(
|
|||
}
|
||||
|
||||
match item {
|
||||
PluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
RawPluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
if let Some(prompt) =
|
||||
resolve_default_prompt_str(plugin_root, &field, prompt)
|
||||
|
|
@ -238,7 +266,7 @@ fn resolve_default_prompts(
|
|||
prompts.push(prompt);
|
||||
}
|
||||
}
|
||||
PluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
RawPluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
|
|
@ -251,7 +279,7 @@ fn resolve_default_prompts(
|
|||
|
||||
(!prompts.is_empty()).then_some(prompts)
|
||||
}
|
||||
PluginManifestDefaultPrompt::Invalid(value) => {
|
||||
RawPluginManifestDefaultPrompt::Invalid(value) => {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
|
|
@ -348,7 +376,7 @@ fn resolve_manifest_path(
|
|||
mod tests {
|
||||
use super::MAX_DEFAULT_PROMPT_LEN;
|
||||
use super::PluginManifest;
|
||||
use super::plugin_manifest_interface;
|
||||
use super::load_plugin_manifest;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
|
@ -369,13 +397,11 @@ mod tests {
|
|||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
let manifest_path = plugin_root.join(".codex-plugin/plugin.json");
|
||||
let contents = fs::read_to_string(manifest_path).expect("read manifest");
|
||||
serde_json::from_str(&contents).expect("parse manifest")
|
||||
load_plugin_manifest(plugin_root).expect("load plugin manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_accepts_legacy_default_prompt_string() {
|
||||
fn plugin_interface_accepts_legacy_default_prompt_string() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
|
|
@ -387,8 +413,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
|
|
@ -397,7 +422,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_normalizes_default_prompt_array() {
|
||||
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);
|
||||
|
|
@ -420,8 +445,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
|
|
@ -434,7 +458,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_interface_ignores_invalid_default_prompt_shape() {
|
||||
fn plugin_interface_ignores_invalid_default_prompt_shape() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
|
|
@ -446,8 +470,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface =
|
||||
plugin_manifest_interface(&manifest, &plugin_root).expect("plugin interface");
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(interface.default_prompt, None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use super::PluginManifestInterfaceSummary;
|
||||
use super::PluginManifestInterface;
|
||||
use super::load_plugin_manifest;
|
||||
use super::plugin_manifest_interface;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use crate::git_info::get_git_repo_root;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
|
|
@ -25,32 +25,40 @@ pub struct ResolvedMarketplacePlugin {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceSummary {
|
||||
pub struct Marketplace {
|
||||
pub name: String,
|
||||
pub path: AbsolutePathBuf,
|
||||
pub interface: Option<MarketplaceInterfaceSummary>,
|
||||
pub plugins: Vec<MarketplacePluginSummary>,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<MarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceInterfaceSummary {
|
||||
pub struct MarketplaceInterface {
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePluginSummary {
|
||||
pub struct MarketplacePlugin {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub install_policy: MarketplacePluginInstallPolicy,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSourceSummary {
|
||||
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: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginInstallPolicy {
|
||||
#[serde(rename = "NOT_AVAILABLE")]
|
||||
|
|
@ -135,7 +143,7 @@ pub fn resolve_marketplace_plugin(
|
|||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
let marketplace = load_marketplace(marketplace_path)?;
|
||||
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
|
|
@ -149,13 +157,13 @@ pub fn resolve_marketplace_plugin(
|
|||
});
|
||||
};
|
||||
|
||||
let MarketplacePlugin {
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
install_policy,
|
||||
auth_policy,
|
||||
policy,
|
||||
..
|
||||
} = plugin;
|
||||
let install_policy = policy.installation;
|
||||
if install_policy == MarketplacePluginInstallPolicy::NotAvailable {
|
||||
return Err(MarketplaceError::PluginNotAvailable {
|
||||
plugin_name: name,
|
||||
|
|
@ -169,56 +177,56 @@ pub fn resolve_marketplace_plugin(
|
|||
Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path, source)?,
|
||||
auth_policy,
|
||||
auth_policy: policy.authentication,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
) -> Result<Vec<Marketplace>, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
pub(crate) fn load_marketplace_summary(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Result<MarketplaceSummary, MarketplaceError> {
|
||||
let marketplace = load_marketplace(path)?;
|
||||
pub(crate) 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 MarketplacePlugin {
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
install_policy,
|
||||
auth_policy,
|
||||
policy,
|
||||
category,
|
||||
} = plugin;
|
||||
let source_path = resolve_plugin_source_path(path, source)?;
|
||||
let source = MarketplacePluginSourceSummary::Local {
|
||||
let source = MarketplacePluginSource::Local {
|
||||
path: source_path.clone(),
|
||||
};
|
||||
let mut interface = load_plugin_manifest(source_path.as_path())
|
||||
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
|
||||
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(PluginManifestInterfaceSummary::default)
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
|
||||
plugins.push(MarketplacePluginSummary {
|
||||
plugins.push(MarketplacePlugin {
|
||||
name,
|
||||
source,
|
||||
install_policy,
|
||||
auth_policy,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: policy.installation,
|
||||
authentication: policy.authentication,
|
||||
products: policy.products,
|
||||
},
|
||||
interface,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MarketplaceSummary {
|
||||
Ok(Marketplace {
|
||||
name: marketplace.name,
|
||||
path: path.clone(),
|
||||
interface: marketplace_interface_summary(marketplace.interface),
|
||||
interface: resolve_marketplace_interface(marketplace.interface),
|
||||
plugins,
|
||||
})
|
||||
}
|
||||
|
|
@ -226,11 +234,11 @@ pub(crate) fn load_marketplace_summary(
|
|||
fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<Vec<MarketplaceSummary>, MarketplaceError> {
|
||||
) -> Result<Vec<Marketplace>, MarketplaceError> {
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
marketplaces.push(load_marketplace_summary(&marketplace_path)?);
|
||||
marketplaces.push(load_marketplace(&marketplace_path)?);
|
||||
}
|
||||
|
||||
Ok(marketplaces)
|
||||
|
|
@ -274,7 +282,9 @@ fn discover_marketplace_paths_from_roots(
|
|||
paths
|
||||
}
|
||||
|
||||
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
|
||||
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 {
|
||||
|
|
@ -292,10 +302,10 @@ fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, Marketpla
|
|||
|
||||
fn resolve_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source: MarketplacePluginSource,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
match source {
|
||||
MarketplacePluginSource::Local { path } => {
|
||||
RawMarketplaceManifestPluginSource::Local { path } => {
|
||||
let Some(path) = path.strip_prefix("./") else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
|
|
@ -373,45 +383,54 @@ fn marketplace_root_dir(
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MarketplaceFile {
|
||||
struct RawMarketplaceManifest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
interface: Option<MarketplaceInterface>,
|
||||
plugins: Vec<MarketplacePlugin>,
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
plugins: Vec<RawMarketplaceManifestPlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MarketplaceInterface {
|
||||
struct RawMarketplaceManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MarketplacePlugin {
|
||||
struct RawMarketplaceManifestPlugin {
|
||||
name: String,
|
||||
source: MarketplacePluginSource,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
#[serde(default)]
|
||||
install_policy: MarketplacePluginInstallPolicy,
|
||||
#[serde(default)]
|
||||
auth_policy: MarketplacePluginAuthPolicy,
|
||||
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,
|
||||
#[serde(default)]
|
||||
products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
enum MarketplacePluginSource {
|
||||
enum RawMarketplaceManifestPluginSource {
|
||||
Local { path: String },
|
||||
}
|
||||
|
||||
fn marketplace_interface_summary(
|
||||
interface: Option<MarketplaceInterface>,
|
||||
) -> Option<MarketplaceInterfaceSummary> {
|
||||
fn resolve_marketplace_interface(
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
) -> Option<MarketplaceInterface> {
|
||||
let interface = interface?;
|
||||
if interface.display_name.is_some() {
|
||||
Some(MarketplaceInterfaceSummary {
|
||||
Some(MarketplaceInterface {
|
||||
display_name: interface.display_name,
|
||||
})
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use super::*;
|
||||
use codex_protocol::protocol::Product;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
@ -132,56 +133,68 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
|||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path:
|
||||
AbsolutePathBuf::try_from(home_root.join(".agents/plugins/marketplace.json"),)
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
MarketplacePlugin {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
MarketplacePlugin {
|
||||
name: "home-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path:
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"),)
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
MarketplacePluginSummary {
|
||||
MarketplacePlugin {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
MarketplacePlugin {
|
||||
name: "repo-only".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
|
|
@ -244,31 +257,37 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
|||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
MarketplaceSummary {
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
MarketplaceSummary {
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
|
|
@ -324,18 +343,21 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
|||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![MarketplaceSummary {
|
||||
vec![Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePluginSummary {
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
|
||||
},
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: vec![],
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
|
|
@ -375,7 +397,7 @@ fn list_marketplaces_reads_marketplace_display_name() {
|
|||
|
||||
assert_eq!(
|
||||
marketplaces[0].interface,
|
||||
Some(MarketplaceInterfaceSummary {
|
||||
Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Official".to_string()),
|
||||
})
|
||||
);
|
||||
|
|
@ -400,8 +422,11 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
|||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "AVAILABLE",
|
||||
"authPolicy": "ON_INSTALL",
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL",
|
||||
"products": ["CODEX", "CHATGPT", "ATLAS"]
|
||||
},
|
||||
"category": "Design"
|
||||
}
|
||||
]
|
||||
|
|
@ -429,16 +454,20 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
|||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].install_policy,
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].auth_policy,
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.products,
|
||||
vec![Product::Codex, Product::Chatgpt, Product::Atlas]
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterfaceSummary {
|
||||
Some(PluginManifestInterface {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
|
|
@ -461,6 +490,47 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "NOT_AVAILABLE",
|
||||
"authPolicy": "ON_USE"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces =
|
||||
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(marketplaces[0].plugins[0].policy.products, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
|
@ -507,7 +577,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
|||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterfaceSummary {
|
||||
Some(PluginManifestInterface {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
|
|
@ -525,13 +595,14 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
|||
})
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].install_policy,
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].auth_policy,
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(marketplaces[0].plugins[0].policy.products, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ pub(crate) use curated_repo::sync_openai_plugins_repo;
|
|||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplacePluginSummary;
|
||||
pub use manager::ConfiguredMarketplaceSummary;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginDetailSummary;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
|
|
@ -38,16 +38,14 @@ pub use manager::installed_plugin_telemetry_metadata;
|
|||
pub use manager::load_plugin_apps;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manifest::PluginManifestInterfaceSummary;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub(crate) use manifest::plugin_manifest_interface;
|
||||
pub(crate) use manifest::plugin_manifest_name;
|
||||
pub(crate) use manifest::plugin_manifest_paths;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplacePluginAuthPolicy;
|
||||
pub use marketplace::MarketplacePluginInstallPolicy;
|
||||
pub use marketplace::MarketplacePluginSourceSummary;
|
||||
pub use marketplace::MarketplacePluginPolicy;
|
||||
pub use marketplace::MarketplacePluginSource;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub use store::PluginId;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use super::load_plugin_manifest;
|
||||
use super::manifest::PLUGIN_MANIFEST_PATH;
|
||||
use super::plugin_manifest_name;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
|
|
@ -211,7 +210,7 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
|
|||
))
|
||||
})?;
|
||||
|
||||
let plugin_name = plugin_manifest_name(&manifest, source_path);
|
||||
let plugin_name = manifest.name;
|
||||
validate_plugin_segment(&plugin_name, "plugin name")
|
||||
.map_err(PluginStoreError::Invalid)
|
||||
.map(|_| plugin_name)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ use codex_protocol::models::FileSystemPermissions;
|
|||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dirs::home_dir;
|
||||
|
|
@ -114,6 +115,8 @@ struct Dependencies {
|
|||
struct Policy {
|
||||
#[serde(default)]
|
||||
allow_implicit_invocation: Option<bool>,
|
||||
#[serde(default)]
|
||||
products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
|
|
@ -735,6 +738,7 @@ fn resolve_dependencies(dependencies: Option<Dependencies>) -> Option<SkillDepen
|
|||
fn resolve_policy(policy: Option<Policy>) -> Option<SkillPolicy> {
|
||||
policy.map(|policy| SkillPolicy {
|
||||
allow_implicit_invocation: policy.allow_implicit_invocation,
|
||||
products: policy.products,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use codex_protocol::models::MacOsContactsPermission;
|
|||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
|
@ -482,6 +483,7 @@ policy:
|
|||
outcome.skills[0].policy,
|
||||
Some(SkillPolicy {
|
||||
allow_implicit_invocation: Some(false),
|
||||
products: vec![],
|
||||
})
|
||||
);
|
||||
assert!(outcome.allowed_skills_for_implicit_invocation().is_empty());
|
||||
|
|
@ -513,6 +515,7 @@ policy: {}
|
|||
outcome.skills[0].policy,
|
||||
Some(SkillPolicy {
|
||||
allow_implicit_invocation: None,
|
||||
products: vec![],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -521,6 +524,41 @@ policy: {}
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_skill_policy_products_from_yaml() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_skill(&codex_home, "demo", "policy-products", "from yaml");
|
||||
let skill_dir = skill_path.parent().expect("skill dir");
|
||||
|
||||
write_skill_metadata_at(
|
||||
skill_dir,
|
||||
r#"
|
||||
policy:
|
||||
products:
|
||||
- codex
|
||||
- CHATGPT
|
||||
- atlas
|
||||
"#,
|
||||
);
|
||||
|
||||
let cfg = make_config(&codex_home).await;
|
||||
let outcome = load_skills_for_test(&cfg);
|
||||
|
||||
assert!(
|
||||
outcome.errors.is_empty(),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
assert_eq!(outcome.skills.len(), 1);
|
||||
assert_eq!(
|
||||
outcome.skills[0].policy,
|
||||
Some(SkillPolicy {
|
||||
allow_implicit_invocation: None,
|
||||
products: vec![Product::Codex, Product::Chatgpt, Product::Atlas],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_skill_permissions_from_yaml() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::path::PathBuf;
|
|||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -43,9 +44,12 @@ impl SkillMetadata {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SkillPolicy {
|
||||
pub allow_implicit_invocation: Option<bool>,
|
||||
// TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and
|
||||
// storing this metadata.
|
||||
pub products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -2908,6 +2908,17 @@ pub struct ListSkillsResponseEvent {
|
|||
pub skills: Vec<SkillsListEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(rename_all = "lowercase")]
|
||||
pub enum Product {
|
||||
#[serde(alias = "CHATGPT")]
|
||||
Chatgpt,
|
||||
#[serde(alias = "CODEX")]
|
||||
Codex,
|
||||
#[serde(alias = "ATLAS")]
|
||||
Atlas,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(rename_all = "snake_case")]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue