mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Handle alternate plugin manifest paths (#18182)
Load plugin manifests through a shared discoverable-path helper so manifest reads, installs, and skill names all see the same alternate manifest location.
This commit is contained in:
committed by
GitHub
Unverified
parent
a803790a10
commit
37161bc76e
@@ -34,6 +34,8 @@ use wiremock::matchers::query_param;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
@@ -246,6 +248,138 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
|
||||
-> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let valid_plugin_root = repo_root.path().join("plugins/valid-plugin");
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(
|
||||
repo_root
|
||||
.path()
|
||||
.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH)
|
||||
.parent()
|
||||
.unwrap(),
|
||||
)?;
|
||||
std::fs::create_dir_all(
|
||||
valid_plugin_root
|
||||
.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH)
|
||||
.parent()
|
||||
.unwrap(),
|
||||
)?;
|
||||
write_plugins_enabled_config(codex_home.path())?;
|
||||
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?;
|
||||
let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?;
|
||||
|
||||
std::fs::write(
|
||||
marketplace_path.as_path(),
|
||||
r#"{
|
||||
"name": "alternate-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": "./plugins/valid-plugin"
|
||||
},
|
||||
{
|
||||
"name": "missing-plugin",
|
||||
"source": "./plugins/missing-plugin"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH),
|
||||
r#"{
|
||||
"name": "valid-plugin",
|
||||
"interface": {
|
||||
"displayName": "Valid Plugin"
|
||||
}
|
||||
}"#,
|
||||
)?;
|
||||
|
||||
let home = codex_home.path().to_string_lossy().into_owned();
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("HOME", Some(home.as_str())),
|
||||
("USERPROFILE", Some(home.as_str())),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response.marketplaces,
|
||||
vec![PluginMarketplaceEntry {
|
||||
name: "alternate-marketplace".to_string(),
|
||||
path: marketplace_path,
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
PluginSummary {
|
||||
id: "valid-plugin@alternate-marketplace".to_string(),
|
||||
name: "valid-plugin".to_string(),
|
||||
source: PluginSource::Local {
|
||||
path: valid_plugin_path,
|
||||
},
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
interface: Some(codex_app_server_protocol::PluginInterface {
|
||||
display_name: Some("Valid Plugin".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
}),
|
||||
},
|
||||
PluginSummary {
|
||||
id: "missing-plugin@alternate-marketplace".to_string(),
|
||||
name: "missing-plugin".to_string(),
|
||||
source: PluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(
|
||||
repo_root.path().join("plugins/missing-plugin"),
|
||||
)?,
|
||||
},
|
||||
installed: false,
|
||||
enabled: false,
|
||||
install_policy: PluginInstallPolicy::Available,
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
}]
|
||||
);
|
||||
assert!(response.marketplace_load_errors.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -512,11 +512,7 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("missing or invalid .codex-plugin/plugin.json")
|
||||
);
|
||||
assert!(err.error.message.contains("missing or invalid plugin.json"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ async fn load_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());
|
||||
loaded_plugin.error = Some("missing or invalid plugin.json".to_string());
|
||||
return loaded_plugin;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use codex_utils_plugins::find_plugin_manifest_path;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
@@ -115,10 +115,7 @@ enum RawPluginManifestDefaultPromptEntry {
|
||||
}
|
||||
|
||||
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 manifest_path = find_plugin_manifest_path(plugin_root)?;
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
match serde_json::from_str::<RawPluginManifest>(&contents) {
|
||||
Ok(manifest) => {
|
||||
@@ -319,11 +316,14 @@ fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) ->
|
||||
}
|
||||
|
||||
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}"
|
||||
);
|
||||
if let Some(manifest_path) = find_plugin_manifest_path(plugin_root) {
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"ignoring {field}: {message}"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("ignoring {field}: {message}");
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_type(value: &JsonValue) -> &'static str {
|
||||
@@ -390,6 +390,8 @@ mod tests {
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
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
|
||||
@@ -408,6 +410,13 @@ mod tests {
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
|
||||
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||
.expect("create manifest dir");
|
||||
fs::write(manifest_path, contents).expect("write manifest");
|
||||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
load_plugin_manifest(plugin_root).expect("load plugin manifest")
|
||||
}
|
||||
@@ -506,4 +515,31 @@ mod tests {
|
||||
|
||||
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_uses_alternate_discoverable_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_alternate_plugin_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"version": " 2.0.0 ",
|
||||
"interface": {
|
||||
"displayName": "Fallback Plugin"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
|
||||
assert_eq!(manifest.version, Some("2.0.0".to_string()));
|
||||
assert_eq!(
|
||||
manifest
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("Fallback Plugin")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,10 @@ const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedMarketplacePlugin {
|
||||
pub plugin_id: PluginId,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
pub manifest: Option<crate::manifest::PluginManifest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -158,12 +160,9 @@ impl MarketplaceError {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
pub fn find_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;
|
||||
@@ -173,39 +172,11 @@ pub fn resolve_marketplace_plugin(
|
||||
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,
|
||||
});
|
||||
if let Some(plugin) =
|
||||
resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)?
|
||||
{
|
||||
return Ok(plugin);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -214,6 +185,31 @@ pub fn resolve_marketplace_plugin(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_installable_marketplace_plugin(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?;
|
||||
let product_allowed = match resolved.policy.products.as_deref() {
|
||||
None => true,
|
||||
Some([]) => false,
|
||||
Some(products) => {
|
||||
restriction_product.is_some_and(|product| product.matches_product_restriction(products))
|
||||
}
|
||||
};
|
||||
if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|
||||
|| !product_allowed
|
||||
{
|
||||
return Err(MarketplaceError::PluginNotAvailable {
|
||||
plugin_name: resolved.plugin_id.plugin_name,
|
||||
marketplace_name: resolved.plugin_id.marketplace_name,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
@@ -270,36 +266,26 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, Marketpla
|
||||
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 plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) {
|
||||
Ok(Some(plugin)) => plugin,
|
||||
Ok(None) => continue,
|
||||
Err(MarketplaceError::InvalidPlugin(message)) => {
|
||||
warn!(
|
||||
path = %path.display(),
|
||||
marketplace = %marketplace.name,
|
||||
error = %message,
|
||||
"skipping invalid marketplace plugin"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
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,
|
||||
name: plugin.plugin_id.plugin_name,
|
||||
source: plugin.source,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,6 +375,48 @@ fn load_raw_marketplace_manifest(
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_marketplace_plugin_entry(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
marketplace_name: &str,
|
||||
plugin: RawMarketplaceManifestPlugin,
|
||||
) -> Result<Option<ResolvedMarketplacePlugin>, MarketplaceError> {
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
policy,
|
||||
category,
|
||||
} = plugin;
|
||||
let Some(source_path) = resolve_supported_plugin_source_path(marketplace_path, &name, source)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let manifest = load_plugin_manifest(source_path.as_path());
|
||||
let mut interface = manifest
|
||||
.as_ref()
|
||||
.and_then(|manifest| manifest.interface.clone());
|
||||
if let Some(category) = category {
|
||||
// Marketplace taxonomy wins when both sources provide a category.
|
||||
interface
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
|
||||
Ok(Some(ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?,
|
||||
source: MarketplacePluginSource::Local { path: source_path },
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: policy.installation,
|
||||
authentication: policy.authentication,
|
||||
products: policy.products,
|
||||
},
|
||||
interface,
|
||||
manifest,
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_supported_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePathBuf {
|
||||
let marketplace_path = repo_root.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH);
|
||||
@@ -13,8 +14,14 @@ fn write_alternate_marketplace(repo_root: &Path, contents: &str) -> AbsolutePath
|
||||
AbsolutePathBuf::try_from(marketplace_path).unwrap()
|
||||
}
|
||||
|
||||
fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
|
||||
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
fs::create_dir_all(manifest_path.parent().unwrap()).unwrap();
|
||||
fs::write(manifest_path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
fn find_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -37,10 +44,9 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -49,14 +55,22 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
manifest: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
|
||||
fn find_marketplace_plugin_supports_alternate_layout_and_string_local_source() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -73,12 +87,7 @@ fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source(
|
||||
}"#,
|
||||
);
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&marketplace_path,
|
||||
"string-source-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
let resolved = find_marketplace_plugin(&marketplace_path, "string-source-plugin").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
@@ -88,15 +97,23 @@ fn resolve_marketplace_plugin_supports_alternate_layout_and_string_local_source(
|
||||
"alternate-marketplace".to_string()
|
||||
)
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin"))
|
||||
.unwrap(),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
manifest: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_reports_missing_plugin() {
|
||||
fn find_marketplace_plugin_reports_missing_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -107,10 +124,9 @@ fn resolve_marketplace_plugin_reports_missing_plugin() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
let err = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"missing",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
@@ -124,8 +140,18 @@ fn resolve_marketplace_plugin_reports_missing_plugin() {
|
||||
fn list_marketplaces_supports_alternate_manifest_layout() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("plugins/string-source-plugin");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
write_alternate_plugin_manifest(
|
||||
&plugin_root,
|
||||
r#"{
|
||||
"name":"string-source-plugin",
|
||||
"interface": {
|
||||
"displayName": "String Source Plugin"
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let marketplace_path = write_alternate_marketplace(
|
||||
&repo_root,
|
||||
r#"{
|
||||
@@ -163,6 +189,70 @@ fn list_marketplaces_supports_alternate_manifest_layout() {
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: Some(PluginManifestInterface {
|
||||
display_name: Some("String Source Plugin".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
}),
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_includes_plugins_without_discoverable_manifest() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
let marketplace_path = write_alternate_marketplace(
|
||||
&repo_root,
|
||||
r#"{
|
||||
"name": "alternate-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "missing-plugin",
|
||||
"source": "./plugins/missing-plugin"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![Marketplace {
|
||||
name: "alternate-marketplace".to_string(),
|
||||
path: marketplace_path,
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "missing-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),)
|
||||
.unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
@@ -448,16 +538,17 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
||||
]
|
||||
);
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
|
||||
resolved.source,
|
||||
MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -621,6 +712,67 @@ fn list_marketplaces_skips_invalid_plugins_but_keeps_marketplace() {
|
||||
assert!(marketplaces[1].plugins.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() {
|
||||
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": "invalid-name-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./valid-plugin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "invalid.plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./invalid-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![Marketplace {
|
||||
name: "invalid-name-marketplace".to_string(),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "valid-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_reports_marketplace_load_errors() {
|
||||
let tmp = tempdir().unwrap();
|
||||
@@ -940,7 +1092,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_skips_invalid_local_paths() {
|
||||
fn find_marketplace_plugin_skips_invalid_local_paths() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -962,10 +1114,9 @@ fn resolve_marketplace_plugin_skips_invalid_local_paths() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
let err = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
@@ -976,7 +1127,7 @@ fn resolve_marketplace_plugin_skips_invalid_local_paths() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_skips_unsupported_sources() {
|
||||
fn find_marketplace_plugin_skips_unsupported_sources() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -996,8 +1147,7 @@ fn resolve_marketplace_plugin_skips_unsupported_sources() {
|
||||
}"#,
|
||||
);
|
||||
|
||||
let err = resolve_marketplace_plugin(&marketplace_path, "remote-plugin", Some(Product::Codex))
|
||||
.unwrap_err();
|
||||
let err = find_marketplace_plugin(&marketplace_path, "remote-plugin").unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
@@ -1006,7 +1156,7 @@ fn resolve_marketplace_plugin_skips_unsupported_sources() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
fn find_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -1035,21 +1185,22 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
|
||||
resolved.source,
|
||||
MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("first")).unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_disallowed_product() {
|
||||
fn find_installable_marketplace_plugin_rejects_disallowed_product() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -1074,7 +1225,7 @@ fn resolve_marketplace_plugin_rejects_disallowed_product() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
let err = find_installable_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"chatgpt-plugin",
|
||||
Some(Product::Atlas),
|
||||
@@ -1088,7 +1239,7 @@ fn resolve_marketplace_plugin_rejects_disallowed_product() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_allows_missing_products_field() {
|
||||
fn find_marketplace_plugin_allows_missing_products_field() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -1111,10 +1262,9 @@ fn resolve_marketplace_plugin_allows_missing_products_field() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"default-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1122,7 +1272,7 @@ fn resolve_marketplace_plugin_allows_missing_products_field() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_explicit_empty_products() {
|
||||
fn find_installable_marketplace_plugin_rejects_explicit_empty_products() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
@@ -1147,7 +1297,7 @@ fn resolve_marketplace_plugin_rejects_explicit_empty_products() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
let err = find_installable_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"disabled-plugin",
|
||||
Some(Product::Codex),
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 codex_utils_plugins::find_plugin_manifest_path;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
@@ -114,7 +114,7 @@ impl PluginStore {
|
||||
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.json name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||
plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
@@ -184,20 +184,8 @@ fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
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()
|
||||
))
|
||||
})
|
||||
load_plugin_manifest(source_path)
|
||||
.ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -210,37 +198,26 @@ struct RawPluginManifestVersion {
|
||||
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 manifest_path = find_plugin_manifest_path(source_path)
|
||||
.ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;
|
||||
|
||||
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()
|
||||
))
|
||||
})?;
|
||||
.map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?;
|
||||
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)
|
||||
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;
|
||||
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()
|
||||
)));
|
||||
return Err(PluginStoreError::Invalid(
|
||||
"invalid plugin version in plugin.json: expected string".to_string(),
|
||||
));
|
||||
};
|
||||
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()
|
||||
)));
|
||||
return Err(PluginStoreError::Invalid(
|
||||
"invalid plugin version in plugin.json: must not be blank".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(version.to_string()))
|
||||
}
|
||||
|
||||
@@ -173,13 +173,9 @@ fn install_rejects_blank_manifest_version() {
|
||||
.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}"
|
||||
assert_eq!(
|
||||
err,
|
||||
"invalid plugin version in plugin.json: must not be blank"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -305,6 +301,6 @@ fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
|
||||
"plugin.json name `manifest-name` does not match marketplace plugin name `different-name`"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,9 +35,10 @@ use codex_core_plugins::marketplace::MarketplacePluginAuthPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginPolicy;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginSource;
|
||||
use codex_core_plugins::marketplace::ResolvedMarketplacePlugin;
|
||||
use codex_core_plugins::marketplace::find_installable_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::find_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace::list_marketplaces;
|
||||
use codex_core_plugins::marketplace::load_marketplace;
|
||||
use codex_core_plugins::marketplace::resolve_marketplace_plugin;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
|
||||
@@ -535,7 +536,7 @@ impl PluginsManager {
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_installable_marketplace_plugin(
|
||||
&request.marketplace_path,
|
||||
&request.plugin_name,
|
||||
self.restriction_product,
|
||||
@@ -549,7 +550,7 @@ impl PluginsManager {
|
||||
auth: Option<&CodexAuth>,
|
||||
request: PluginInstallRequest,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
let resolved = find_installable_marketplace_plugin(
|
||||
&request.marketplace_path,
|
||||
&request.plugin_name,
|
||||
self.restriction_product,
|
||||
@@ -572,7 +573,7 @@ impl PluginsManager {
|
||||
&self,
|
||||
resolved: ResolvedMarketplacePlugin,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let auth_policy = resolved.auth_policy;
|
||||
let auth_policy = resolved.policy.authentication;
|
||||
let plugin_version =
|
||||
if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
Some(
|
||||
@@ -587,10 +588,11 @@ impl PluginsManager {
|
||||
};
|
||||
let store = self.store.clone();
|
||||
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
|
||||
let MarketplacePluginSource::Local { path: source_path } = resolved.source;
|
||||
if let Some(plugin_version) = plugin_version {
|
||||
store.install_with_version(resolved.source_path, resolved.plugin_id, plugin_version)
|
||||
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
|
||||
} else {
|
||||
store.install(resolved.source_path, resolved.plugin_id)
|
||||
store.install(source_path, resolved.plugin_id)
|
||||
}
|
||||
})
|
||||
.await
|
||||
@@ -978,32 +980,24 @@ impl PluginsManager {
|
||||
return Err(MarketplaceError::PluginsDisabled);
|
||||
}
|
||||
|
||||
let marketplace = load_marketplace(&request.marketplace_path)?;
|
||||
let marketplace_name = marketplace.name.clone();
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.name == request.plugin_name);
|
||||
let Some(plugin) = plugin else {
|
||||
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
|
||||
return Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: request.plugin_name.clone(),
|
||||
marketplace_name,
|
||||
plugin_name: plugin.plugin_id.plugin_name,
|
||||
marketplace_name: plugin.plugin_id.marketplace_name,
|
||||
});
|
||||
};
|
||||
let plugin_id = PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(
|
||||
|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
},
|
||||
)?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
}
|
||||
|
||||
let marketplace_name = plugin.plugin_id.marketplace_name.clone();
|
||||
let plugin_key = plugin.plugin_id.as_key();
|
||||
let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config);
|
||||
let plugin = self
|
||||
.read_plugin_detail_for_marketplace_plugin(
|
||||
config,
|
||||
&marketplace.name,
|
||||
&marketplace_name,
|
||||
ConfiguredMarketplacePlugin {
|
||||
id: plugin_key.clone(),
|
||||
name: plugin.name,
|
||||
name: plugin.plugin_id.plugin_name,
|
||||
source: plugin.source,
|
||||
policy: plugin.policy,
|
||||
interface: plugin.interface,
|
||||
@@ -1014,12 +1008,12 @@ impl PluginsManager {
|
||||
.await?;
|
||||
|
||||
Ok(PluginReadOutcome {
|
||||
marketplace_name: if marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
marketplace_name: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()
|
||||
} else {
|
||||
marketplace.name
|
||||
marketplace_name
|
||||
},
|
||||
marketplace_path: marketplace.path,
|
||||
marketplace_path: request.marketplace_path.clone(),
|
||||
plugin,
|
||||
})
|
||||
}
|
||||
@@ -1037,13 +1031,6 @@ impl PluginsManager {
|
||||
});
|
||||
}
|
||||
|
||||
let plugin_id =
|
||||
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
|
||||
match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
}
|
||||
})?;
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let source_path = match &plugin.source {
|
||||
MarketplacePluginSource::Local { path } => path.clone(),
|
||||
};
|
||||
@@ -1053,20 +1040,16 @@ impl PluginsManager {
|
||||
));
|
||||
}
|
||||
let manifest = load_plugin_manifest(source_path.as_path()).ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin(
|
||||
"missing or invalid .codex-plugin/plugin.json".to_string(),
|
||||
)
|
||||
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
|
||||
})?;
|
||||
let description = manifest.description.clone();
|
||||
let manifest_paths = &manifest.paths;
|
||||
let skill_config_rules = codex_core_skills::config_rules::skill_config_rules_from_stack(
|
||||
&config.config_layer_stack,
|
||||
);
|
||||
let resolved_skills = load_plugin_skills(
|
||||
&source_path,
|
||||
manifest_paths,
|
||||
&manifest.paths,
|
||||
self.restriction_product,
|
||||
&skill_config_rules,
|
||||
&codex_core_skills::config_rules::skill_config_rules_from_stack(
|
||||
&config.config_layer_stack,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let apps = load_plugin_apps(source_path.as_path()).await;
|
||||
@@ -1078,7 +1061,7 @@ impl PluginsManager {
|
||||
mcp_server_names.dedup();
|
||||
|
||||
Ok(PluginDetail {
|
||||
id: plugin_key,
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
description,
|
||||
source: plugin.source,
|
||||
|
||||
@@ -1578,7 +1578,13 @@ source = "/tmp/debug"
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.find(|marketplace| {
|
||||
marketplace.path
|
||||
== AbsolutePathBuf::try_from(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.expect("installed marketplace should be listed");
|
||||
|
||||
assert_eq!(
|
||||
@@ -1648,7 +1654,13 @@ source = "/tmp/debug"
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.find(|marketplace| {
|
||||
marketplace.path
|
||||
== AbsolutePathBuf::try_from(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.expect("configured marketplace should be discovered");
|
||||
|
||||
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
||||
@@ -1695,7 +1707,16 @@ plugins = true
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert!(marketplaces.is_empty());
|
||||
assert!(
|
||||
marketplaces.iter().all(|marketplace| {
|
||||
marketplace.path
|
||||
!= AbsolutePathBuf::try_from(
|
||||
marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.unwrap()
|
||||
}),
|
||||
"installed marketplace root missing from config should not be listed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Shared plugin identifiers and telemetry-facing summaries.
|
||||
|
||||
pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
pub use codex_utils_plugins::mention_syntax;
|
||||
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ pub mod mcp_connector;
|
||||
pub mod mention_syntax;
|
||||
pub mod plugin_namespace;
|
||||
|
||||
pub use plugin_namespace::PLUGIN_MANIFEST_PATH;
|
||||
pub use plugin_namespace::find_plugin_manifest_path;
|
||||
pub use plugin_namespace::plugin_namespace_for_skill_path;
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Relative path from a plugin root to its manifest file.
|
||||
pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const DISCOVERABLE_PLUGIN_MANIFEST_PATHS: &[&str] =
|
||||
&[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"];
|
||||
|
||||
pub fn find_plugin_manifest_path(plugin_root: &Path) -> Option<PathBuf> {
|
||||
DISCOVERABLE_PLUGIN_MANIFEST_PATHS
|
||||
.iter()
|
||||
.map(|relative_path| plugin_root.join(relative_path))
|
||||
.find(|manifest_path| manifest_path.is_file())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -17,11 +26,18 @@ async fn plugin_manifest_name(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> Option<String> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
match fs.get_metadata(&manifest_path, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {}
|
||||
Ok(_) | Err(_) => return None,
|
||||
let mut manifest_path = None;
|
||||
for relative_path in DISCOVERABLE_PLUGIN_MANIFEST_PATHS {
|
||||
let candidate = plugin_root.join(relative_path);
|
||||
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {
|
||||
manifest_path = Some(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
let manifest_path = manifest_path?;
|
||||
let contents = fs
|
||||
.read_file_text(&manifest_path, /*sandbox*/ None)
|
||||
.await
|
||||
@@ -53,12 +69,15 @@ pub async fn plugin_namespace_for_skill_path(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_plugin_manifest_path;
|
||||
use super::plugin_namespace_for_skill_path;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_manifest_name() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -79,4 +98,24 @@ mod tests {
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_name_from_alternate_discoverable_manifest_path() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
|
||||
fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir");
|
||||
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
|
||||
.expect("mkdir manifest");
|
||||
fs::write(&manifest_path, r#"{"name":"sample"}"#).expect("write manifest");
|
||||
fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(LOCAL_FS.as_ref(), &skill_path.abs()).await,
|
||||
Some("sample".to_string())
|
||||
);
|
||||
assert_eq!(find_plugin_manifest_path(&plugin_root), Some(manifest_path));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user