feat: Add curated plugin marketplace + Metadata Cleanup. (#13712)

1. Add a synced curated plugin marketplace and include it in marketplace
discovery.
2. Expose optional plugin.json interface metadata in plugin/list
3. Tighten plugin and marketplace path handling using validated absolute
paths.
4. Let manifests override skill, MCP, and app config paths.
5. Restrict plugin enablement/config loading to the user config layer so
plugin enablement is at global level
This commit is contained in:
xl-openai
2026-03-06 19:39:35 -05:00
committed by GitHub
parent 289ed549cf
commit 0243734300
23 changed files with 2107 additions and 183 deletions

View File

@@ -0,0 +1,353 @@
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::thread;
use std::time::Duration;
use std::time::Instant;
const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git";
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
}
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> {
let repo_path = curated_plugins_repo_path(codex_home);
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
let remote_sha = git_ls_remote_head_sha()?;
let local_sha = read_local_sha(&repo_path, &sha_path);
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
return Ok(());
}
let Some(parent) = repo_path.parent() else {
return Err(format!(
"failed to determine curated plugins parent directory for {}",
repo_path.display()
));
};
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins parent directory {}: {err}",
parent.display()
)
})?;
let clone_dir = tempfile::Builder::new()
.prefix("plugins-clone-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create temporary curated plugins directory in {}: {err}",
parent.display()
)
})?;
let cloned_repo_path = clone_dir.path().join("repo");
let clone_output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("clone")
.arg("--depth")
.arg("1")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg(&cloned_repo_path),
"git clone curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
let cloned_sha = git_head_sha(&cloned_repo_path)?;
if cloned_sha != remote_sha {
return Err(format!(
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
));
}
if repo_path.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugins-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create curated plugins backup directory in {}: {err}",
parent.display()
)
})?;
let backup_repo_path = backup_dir.path().join("repo");
fs::rename(&repo_path, &backup_repo_path).map_err(|err| {
format!(
"failed to move previous curated plugins repo out of the way at {}: {err}",
repo_path.display()
)
})?;
if let Err(err) = fs::rename(&cloned_repo_path, &repo_path) {
let rollback_result = fs::rename(&backup_repo_path, &repo_path);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate new curated plugins repo at {}: {err}",
repo_path.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("repo");
Err(format!(
"failed to activate new curated plugins repo at {}: {err}; failed to restore previous repo (left at {}): {rollback_err}",
repo_path.display(),
backup_path.display()
))
}
};
}
} else {
fs::rename(&cloned_repo_path, &repo_path).map_err(|err| {
format!(
"failed to activate curated plugins repo at {}: {err}",
repo_path.display()
)
})?;
}
if let Some(parent) = sha_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create curated plugins sha directory {}: {err}",
parent.display()
)
})?;
}
fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| {
format!(
"failed to write curated plugins sha file {}: {err}",
sha_path.display()
)
})?;
Ok(())
}
fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option<String> {
if repo_path.join(".git").is_dir()
&& let Ok(sha) = git_head_sha(repo_path)
{
return Some(sha);
}
fs::read_to_string(sha_path)
.ok()
.map(|sha| sha.trim().to_string())
.filter(|sha| !sha.is_empty())
}
fn git_ls_remote_head_sha() -> Result<String, String> {
let output = run_git_command_with_timeout(
Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("ls-remote")
.arg(OPENAI_PLUGINS_REPO_URL)
.arg("HEAD"),
"git ls-remote curated plugins repo",
CURATED_PLUGINS_GIT_TIMEOUT,
)?;
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
};
let Some((sha, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for curated plugins repo: {first_line}"
));
};
if sha.is_empty() {
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
}
Ok(sha.to_string())
}
fn git_head_sha(repo_path: &Path) -> Result<String, String> {
let output = Command::new("git")
.env("GIT_OPTIONAL_LOCKS", "0")
.arg("-C")
.arg(repo_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.map_err(|err| {
format!(
"failed to run git rev-parse HEAD in {}: {err}",
repo_path.display()
)
})?;
ensure_git_success(&output, "git rev-parse HEAD")?;
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sha.is_empty() {
return Err(format!(
"git rev-parse HEAD returned empty output in {}",
repo_path.display()
));
}
Ok(sha)
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
if start.elapsed() >= timeout {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
};
}
thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
let tmp = tempdir().expect("tempdir");
assert_eq!(
curated_plugins_repo_path(tmp.path()),
tmp.path().join(".tmp/plugins")
);
}
#[test]
fn read_local_sha_prefers_repo_head_when_available() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::create_dir_all(&repo_path).expect("create repo dir");
fs::write(&sha_path, "abc123\n").expect("write sha");
let init_output = Command::new("git")
.arg("init")
.arg(&repo_path)
.output()
.expect("git init should run");
ensure_git_success(&init_output, "git init").expect("git init should succeed");
let config_name_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.name")
.arg("Codex")
.output()
.expect("git config user.name should run");
ensure_git_success(&config_name_output, "git config user.name")
.expect("git config user.name should succeed");
let config_email_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("config")
.arg("user.email")
.arg("codex@example.com")
.output()
.expect("git config user.email should run");
ensure_git_success(&config_email_output, "git config user.email")
.expect("git config user.email should succeed");
fs::write(repo_path.join("README.md"), "demo\n").expect("write file");
let add_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("add")
.arg(".")
.output()
.expect("git add should run");
ensure_git_success(&add_output, "git add").expect("git add should succeed");
let commit_output = Command::new("git")
.arg("-C")
.arg(&repo_path)
.arg("commit")
.arg("-m")
.arg("init")
.output()
.expect("git commit should run");
ensure_git_success(&commit_output, "git commit").expect("git commit should succeed");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha")));
}
#[test]
fn read_local_sha_falls_back_to_sha_file() {
let tmp = tempdir().expect("tempdir");
let repo_path = tmp.path().join("repo");
let sha_path = tmp.path().join("plugins.sha");
fs::write(&sha_path, "abc123\n").expect("write sha");
let sha = read_local_sha(&repo_path, &sha_path);
assert_eq!(sha.as_deref(), Some("abc123"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,89 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::fs;
use std::path::Component;
use std::path::Path;
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PluginManifest {
#[serde(default)]
pub(crate) name: String,
#[serde(default)]
pub(crate) description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<String>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
#[serde(alias = "websiteURL")]
website_url: Option<String>,
#[serde(default)]
#[serde(alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default)]
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<String>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
@@ -36,3 +112,123 @@ pub(crate) fn plugin_manifest_name(manifest: &PluginManifest, plugin_root: &Path
.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: interface.default_prompt.clone(),
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,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
// `plugin.json` paths are required to be relative to the plugin root and we return the
// normalized absolute path to the rest of the system.
let path = path?;
if path.is_empty() {
return None;
}
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = std::path::PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized))
.map_err(|err| {
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
err
})
.ok()
}

View File

@@ -1,3 +1,6 @@
use super::PluginManifestInterfaceSummary;
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;
@@ -21,7 +24,7 @@ pub struct ResolvedMarketplacePlugin {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceSummary {
pub name: String,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub plugins: Vec<MarketplacePluginSummary>,
}
@@ -29,11 +32,12 @@ pub struct MarketplaceSummary {
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub interface: Option<PluginManifestInterfaceSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSourceSummary {
Local { path: PathBuf },
Local { path: AbsolutePathBuf },
}
#[derive(Debug, thiserror::Error)]
@@ -73,7 +77,7 @@ pub fn resolve_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(marketplace_path)?;
let marketplace_name = marketplace.name;
let plugin = marketplace
.plugins
@@ -92,7 +96,7 @@ pub fn resolve_marketplace_plugin(
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path.as_path(), plugin.source)?,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
})
}
@@ -109,23 +113,21 @@ fn list_marketplaces_with_home(
let mut marketplaces = Vec::new();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
let marketplace = load_marketplace(marketplace_path.as_path())?;
let marketplace = load_marketplace(&marketplace_path)?;
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source = match plugin.source {
MarketplacePluginSource::Local { path } => MarketplacePluginSourceSummary::Local {
path: resolve_plugin_source_path(
marketplace_path.as_path(),
MarketplacePluginSource::Local { path },
)?
.into_path_buf(),
},
let source_path = resolve_plugin_source_path(&marketplace_path, plugin.source)?;
let source = MarketplacePluginSourceSummary::Local {
path: source_path.clone(),
};
let interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
plugins.push(MarketplacePluginSummary {
name: plugin.name,
source,
interface,
});
}
@@ -142,30 +144,34 @@ fn list_marketplaces_with_home(
fn discover_marketplace_paths_from_roots(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Vec<PathBuf> {
) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
if let Some(home) = home_dir {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
if path.is_file()
&& let Ok(path) = AbsolutePathBuf::try_from(path)
{
paths.push(path);
}
}
for root in additional_roots {
if let Some(repo_root) = get_git_repo_root(root.as_path()) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() && !paths.contains(&path) {
paths.push(path);
}
if let Some(repo_root) = get_git_repo_root(root.as_path())
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
&& path.as_path().is_file()
&& !paths.contains(&path)
{
paths.push(path);
}
}
paths
}
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path).map_err(|err| {
fn load_marketplace(path: &AbsolutePathBuf) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
MarketplaceError::MarketplaceNotFound {
path: path.to_path_buf(),
@@ -181,7 +187,7 @@ fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
}
fn resolve_plugin_source_path(
marketplace_path: &Path,
marketplace_path: &AbsolutePathBuf,
source: MarketplacePluginSource,
) -> Result<AbsolutePathBuf, MarketplaceError> {
match source {
@@ -206,25 +212,61 @@ fn resolve_plugin_source_path(
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must stay within the marketplace directory"
message: "local plugin source path must stay within the marketplace root"
.to_string(),
});
}
let source_path = marketplace_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(relative_source_path);
AbsolutePathBuf::try_from(source_path).map_err(|err| {
MarketplaceError::InvalidMarketplaceFile {
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
marketplace_root_dir(marketplace_path)?
.join(relative_source_path)
.map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: format!("plugin source path must resolve to an absolute path: {err}"),
}
})
})
}
}
}
fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
let Some(plugins_dir) = marketplace_path.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(dot_agents_dir) = plugins_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
let Some(marketplace_root) = dot_agents_dir.parent() else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
};
if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins")
|| dot_agents_dir
.as_path()
.file_name()
.and_then(|s| s.to_str())
!= Some(".agents")
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
});
}
Ok(marketplace_root)
}
#[derive(Debug, Deserialize)]
struct MarketplaceFile {
name: String,
@@ -284,8 +326,7 @@ mod tests {
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/plugin-1"))
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
}
);
}
@@ -381,37 +422,51 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
home_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-shared"),
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "home-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-only"),
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
.unwrap(),
},
interface: None,
},
],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
plugins: vec![
MarketplacePluginSummary {
name: "shared-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-shared"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
.unwrap(),
},
interface: None,
},
MarketplacePluginSummary {
name: "repo-only".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-only"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
.unwrap(),
},
interface: None,
},
],
},
@@ -475,22 +530,24 @@ mod tests {
vec![
MarketplaceSummary {
name: "codex-curated".to_string(),
path: home_marketplace,
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: home_root.join(".agents/plugins/home-plugin"),
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
interface: None,
}],
},
MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_marketplace.clone(),
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/repo-plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
interface: None,
}],
},
]
@@ -504,7 +561,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/repo-plugin")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
);
}
@@ -547,17 +604,152 @@ mod tests {
marketplaces,
vec![MarketplaceSummary {
name: "codex-curated".to_string(),
path: repo_root.join(".agents/plugins/marketplace.json"),
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
plugins: vec![MarketplacePluginSummary {
name: "local-plugin".to_string(),
source: MarketplacePluginSourceSummary::Local {
path: repo_root.join(".agents/plugins/plugin"),
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
interface: None,
}],
}]
);
}
#[test]
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).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"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive", "Write"],
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"screenshots": ["./assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: Some(
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
),
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
screenshots: vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
],
})
);
}
#[test]
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/demo-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).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"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"capabilities": ["Interactive"],
"composerIcon": "assets/icon.png",
"logo": "/tmp/logo.png",
"screenshots": ["assets/shot1.png"]
}
}"#,
)
.unwrap();
let marketplaces =
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
display_name: Some("Demo".to_string()),
short_description: None,
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Interactive".to_string()],
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 resolve_marketplace_plugin_rejects_non_relative_local_paths() {
let tmp = tempdir().unwrap();
@@ -634,7 +826,7 @@ mod tests {
assert_eq!(
resolved.source_path,
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/first")).unwrap()
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
);
}
}

View File

@@ -1,3 +1,4 @@
mod curated_repo;
mod injection;
mod manager;
mod manifest;
@@ -5,6 +6,8 @@ mod marketplace;
mod render;
mod store;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
@@ -17,8 +20,12 @@ pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub use manager::load_plugin_apps;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub use manifest::PluginManifestInterfaceSummary;
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::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;

View File

@@ -61,7 +61,7 @@ impl PluginId {
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: PathBuf,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
@@ -92,19 +92,25 @@ impl PluginStore {
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION)
.as_path()
.is_dir()
}
pub fn install(
&self,
source_path: PathBuf,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.is_dir() {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(&source_path)?;
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 `{}`",
@@ -112,18 +118,16 @@ impl PluginStore {
)));
}
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
let installed_path = self
.plugin_root(&plugin_id, &plugin_version)
.into_path_buf();
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
if let Some(parent) = installed_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
fs::create_dir_all(parent.as_path()).map_err(|err| {
PluginStoreError::io("failed to create plugin cache directory", err)
})?;
}
remove_existing_target(&installed_path)?;
copy_dir_recursive(&source_path, &installed_path)?;
remove_existing_target(installed_path.as_path())?;
copy_dir_recursive(source_path.as_path(), installed_path.as_path())?;
Ok(PluginInstallResult {
plugin_id,
@@ -257,7 +261,10 @@ mod tests {
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
@@ -266,7 +273,7 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: installed_path.clone(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
@@ -280,7 +287,10 @@ mod tests {
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(tmp.path().join("source-dir"), plugin_id.clone())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
@@ -288,7 +298,10 @@ mod tests {
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
installed_path: AbsolutePathBuf::try_from(
tmp.path().join("plugins/cache/market/manifest-name/local"),
)
.unwrap(),
}
);
}
@@ -327,7 +340,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
@@ -355,7 +368,7 @@ mod tests {
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();