plugin: support local-based marketplace.json + install endpoint. (#13422)

Support marketplace.json that points to a local file, with
```
    "source":
    {
        "source": "local",
        "path": "./plugin-1"
    },
 ```
 
 Add a new plugin/install endpoint which add the plugin to the cache folder and enable it in config.toml.
This commit is contained in:
xl-openai
2026-03-04 19:08:18 -05:00
committed by GitHub
Unverified
parent 294079b0b1
commit 1e877ccdd2
17 changed files with 756 additions and 69 deletions
+61 -7
View File
@@ -1,8 +1,9 @@
use super::load_plugin_manifest;
use super::marketplace::MarketplaceError;
use super::marketplace::resolve_marketplace_plugin;
use super::plugin_manifest_name;
use super::store::DEFAULT_PLUGIN_VERSION;
use super::store::PluginId;
use super::store::PluginInstallRequest;
use super::store::PluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
@@ -38,6 +39,13 @@ const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AppConnectorId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
pub plugin_name: String,
pub marketplace_name: String,
pub cwd: PathBuf,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LoadedPlugin {
pub config_name: String,
@@ -169,10 +177,17 @@ impl PluginsManager {
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
let resolved = resolve_marketplace_plugin(
&request.cwd,
&request.plugin_name,
&request.marketplace_name,
)?;
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || store.install(request))
.await
.map_err(PluginInstallError::join)??;
let result = tokio::task::spawn_blocking(move || {
store.install(resolved.source_path.into_path_buf(), resolved.plugin_id)
})
.await
.map_err(PluginInstallError::join)??;
ConfigService::new_with_defaults(self.codex_home.clone())
.write_value(ConfigValueWriteParams {
@@ -194,6 +209,9 @@ impl PluginsManager {
#[derive(Debug, thiserror::Error)]
pub enum PluginInstallError {
#[error("{0}")]
Marketplace(#[from] MarketplaceError),
#[error("{0}")]
Store(#[from] PluginStoreError),
@@ -208,6 +226,18 @@ impl PluginInstallError {
fn join(source: tokio::task::JoinError) -> Self {
Self::Join(source)
}
pub fn is_invalid_request(&self) -> bool {
matches!(
self,
Self::Marketplace(
MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::DuplicatePlugin { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
}
}
fn plugins_feature_enabled_from_stack(config_layer_stack: &ConfigLayerStack) -> bool {
@@ -879,12 +909,36 @@ mod tests {
#[tokio::test]
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
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();
write_plugin(
&repo_root.join(".agents/plugins"),
"sample-plugin",
"sample-plugin",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
)
.unwrap();
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
source_path: tmp.path().join("sample-plugin"),
marketplace_name: None,
plugin_name: "sample-plugin".to_string(),
marketplace_name: "debug".to_string(),
cwd: repo_root.clone(),
})
.await
.unwrap();
+370
View File
@@ -0,0 +1,370 @@
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use serde::Deserialize;
use std::fs;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMarketplacePlugin {
pub plugin_id: PluginId,
pub source_path: AbsolutePathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceError {
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: io::Error,
},
#[error("invalid marketplace file `{path}`: {message}")]
InvalidMarketplaceFile { path: PathBuf, message: String },
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
PluginNotFound {
plugin_name: String,
marketplace_name: String,
},
#[error(
"multiple marketplace plugin entries matched `{plugin_name}` in marketplace `{marketplace_name}`"
)]
DuplicatePlugin {
plugin_name: String,
marketplace_name: String,
},
#[error("{0}")]
InvalidPlugin(String),
}
impl MarketplaceError {
fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
// For now, marketplace discovery always reads from disk so installs see the latest
// marketplace.json contents without any in-memory cache invalidation.
pub fn resolve_marketplace_plugin(
cwd: &Path,
plugin_name: &str,
marketplace_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
resolve_marketplace_plugin_from_paths(
&discover_marketplace_paths(cwd),
plugin_name,
marketplace_name,
)
}
fn resolve_marketplace_plugin_from_paths(
marketplace_paths: &[PathBuf],
plugin_name: &str,
marketplace_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
for marketplace_path in marketplace_paths {
let marketplace = load_marketplace(marketplace_path)?;
let discovered_marketplace_name = marketplace.name;
let mut matches = marketplace
.plugins
.into_iter()
.filter(|plugin| plugin.name == plugin_name)
.collect::<Vec<_>>();
if discovered_marketplace_name != marketplace_name || matches.is_empty() {
continue;
}
if matches.len() > 1 {
return Err(MarketplaceError::DuplicatePlugin {
plugin_name: plugin_name.to_string(),
marketplace_name: marketplace_name.to_string(),
});
}
if let Some(plugin) = matches.pop() {
let plugin_id = PluginId::new(plugin.name, marketplace_name.to_string()).map_err(
|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
},
)?;
return Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
});
}
}
Err(MarketplaceError::PluginNotFound {
plugin_name: plugin_name.to_string(),
marketplace_name: marketplace_name.to_string(),
})
}
fn discover_marketplace_paths(cwd: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(repo_root) = get_git_repo_root(cwd) {
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
paths.push(path);
}
}
if let Some(home) = home_dir() {
let path = home.join(MARKETPLACE_RELATIVE_PATH);
if path.is_file() {
paths.push(path);
}
}
paths
}
fn load_marketplace(path: &Path) -> Result<MarketplaceFile, MarketplaceError> {
let contents = fs::read_to_string(path)
.map_err(|err| MarketplaceError::io("failed to read marketplace file", err))?;
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: path.to_path_buf(),
message: err.to_string(),
})
}
fn resolve_plugin_source_path(
marketplace_path: &Path,
source: MarketplacePluginSource,
) -> Result<AbsolutePathBuf, MarketplaceError> {
match source {
MarketplacePluginSource::Local { path } => {
let Some(path) = path.strip_prefix("./") else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must start with `./`".to_string(),
});
};
if path.is_empty() {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must not be empty".to_string(),
});
}
let relative_source_path = Path::new(path);
if relative_source_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must stay within the marketplace directory"
.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 {
path: marketplace_path.to_path_buf(),
message: format!("plugin source path must resolve to an absolute path: {err}"),
}
})
}
}
}
#[derive(Debug, Deserialize)]
struct MarketplaceFile {
name: String,
plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Deserialize)]
struct MarketplacePlugin {
name: String,
source: MarketplacePluginSource,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
enum MarketplacePluginSource {
Local { path: String },
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn resolve_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();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(repo_root.join("nested")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./plugin-1"
}
}
]
}"#,
)
.unwrap();
let resolved =
resolve_marketplace_plugin(&repo_root.join("nested"), "local-plugin", "codex-curated")
.unwrap();
assert_eq!(
resolved,
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(),
}
);
}
#[test]
fn resolve_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();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{"name":"codex-curated","plugins":[]}"#,
)
.unwrap();
let err = resolve_marketplace_plugin(&repo_root, "missing", "codex-curated").unwrap_err();
assert_eq!(
err.to_string(),
"plugin `missing` was not found in marketplace `codex-curated`"
);
}
#[test]
fn resolve_marketplace_plugin_prefers_repo_over_home_for_same_plugin() {
let tmp = tempdir().unwrap();
let home_root = tmp.path().join("home");
let repo_root = tmp.path().join("repo");
let home_marketplace = home_root.join(".agents/plugins/marketplace.json");
let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
home_marketplace.clone(),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./home-plugin"
}
}
]
}"#,
)
.unwrap();
fs::write(
repo_marketplace.clone(),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "local-plugin",
"source": {
"source": "local",
"path": "./repo-plugin"
}
}
]
}"#,
)
.unwrap();
let resolved = resolve_marketplace_plugin_from_paths(
&[repo_marketplace, home_marketplace],
"local-plugin",
"codex-curated",
)
.unwrap();
assert_eq!(
resolved,
ResolvedMarketplacePlugin {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/repo-plugin"),
)
.unwrap(),
}
);
}
#[test]
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
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": "local-plugin",
"source": {
"source": "local",
"path": "../plugin-1"
}
}
]
}"#,
)
.unwrap();
let err =
resolve_marketplace_plugin(&repo_root, "local-plugin", "codex-curated").unwrap_err();
assert_eq!(
err.to_string(),
format!(
"invalid marketplace file `{}`: local plugin source path must start with `./`",
repo_root.join(".agents/plugins/marketplace.json").display()
)
);
}
}
+2 -1
View File
@@ -1,15 +1,16 @@
mod manager;
mod manifest;
mod marketplace;
mod store;
pub use manager::AppConnectorId;
pub use manager::LoadedPlugin;
pub use manager::PluginInstallError;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginsManager;
pub(crate) use manager::plugin_namespace_for_skill_path;
pub(crate) use manifest::load_plugin_manifest;
pub(crate) use manifest::plugin_manifest_name;
pub use store::PluginId;
pub use store::PluginInstallRequest;
pub use store::PluginInstallResult;
+54 -60
View File
@@ -7,14 +7,13 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
const DEFAULT_MARKETPLACE_NAME: &str = "debug";
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallRequest {
pub source_path: PathBuf,
pub marketplace_name: Option<String>,
#[derive(Debug, thiserror::Error)]
pub enum PluginIdError {
#[error("{0}")]
Invalid(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -24,34 +23,32 @@ pub struct PluginId {
}
impl PluginId {
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginStoreError> {
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::InvalidPluginKey)?;
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
validate_plugin_segment(&marketplace_name, "marketplace name")
.map_err(PluginStoreError::InvalidPluginKey)?;
.map_err(PluginIdError::Invalid)?;
Ok(Self {
plugin_name,
marketplace_name,
})
}
pub fn parse(plugin_key: &str) -> Result<Self, PluginStoreError> {
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
return Err(PluginStoreError::InvalidPluginKey(format!(
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
};
if plugin_name.is_empty() || marketplace_name.is_empty() {
return Err(PluginStoreError::InvalidPluginKey(format!(
return Err(PluginIdError::Invalid(format!(
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
)));
}
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
PluginStoreError::InvalidPluginKey(message) => {
PluginStoreError::InvalidPluginKey(format!("{message} in `{plugin_key}`"))
PluginIdError::Invalid(message) => {
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
}
other => other,
})
}
@@ -97,29 +94,24 @@ impl PluginStore {
pub fn install(
&self,
request: PluginInstallRequest,
source_path: PathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
let source_path = request.source_path;
if !source_path.is_dir() {
return Err(PluginStoreError::InvalidPlugin(format!(
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 marketplace_name = request
.marketplace_name
.filter(|name| !name.trim().is_empty())
.unwrap_or_else(|| DEFAULT_MARKETPLACE_NAME.to_string());
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
plugin_id.plugin_name
)));
}
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
let plugin_id = match PluginId::new(plugin_name, marketplace_name) {
Ok(plugin_id) => plugin_id,
Err(PluginStoreError::InvalidPluginKey(message)) => {
return Err(PluginStoreError::InvalidPlugin(message));
}
Err(err) => return Err(err),
};
let installed_path = self
.plugin_root(&plugin_id, &plugin_version)
.into_path_buf();
@@ -151,10 +143,7 @@ pub enum PluginStoreError {
},
#[error("{0}")]
InvalidPlugin(String),
#[error("{0}")]
InvalidPluginKey(String),
Invalid(String),
}
impl PluginStoreError {
@@ -166,14 +155,14 @@ impl PluginStoreError {
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
if !manifest_path.is_file() {
return Err(PluginStoreError::InvalidPlugin(format!(
return Err(PluginStoreError::Invalid(format!(
"missing plugin manifest: {}",
manifest_path.display()
)));
}
let manifest = load_plugin_manifest(source_path).ok_or_else(|| {
PluginStoreError::InvalidPlugin(format!(
PluginStoreError::Invalid(format!(
"missing or invalid plugin manifest: {}",
manifest_path.display()
))
@@ -181,7 +170,7 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
let plugin_name = plugin_manifest_name(&manifest, source_path);
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::InvalidPlugin)
.map_err(PluginStoreError::Invalid)
.map(|_| plugin_name)
}
@@ -265,19 +254,17 @@ mod tests {
fn install_copies_plugin_into_default_marketplace() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(PluginInstallRequest {
source_path: tmp.path().join("sample-plugin"),
marketplace_name: None,
})
.install(tmp.path().join("sample-plugin"), plugin_id.clone())
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_id,
plugin_version: "local".to_string(),
installed_path: installed_path.clone(),
}
@@ -290,19 +277,16 @@ mod tests {
fn install_uses_manifest_name_for_destination_and_key() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(PluginInstallRequest {
source_path: tmp.path().join("source-dir"),
marketplace_name: Some("market".to_string()),
})
.install(tmp.path().join("source-dir"), plugin_id.clone())
.unwrap();
assert_eq!(
result,
PluginInstallResult {
plugin_id: PluginId::new("manifest-name".to_string(), "market".to_string())
.unwrap(),
plugin_id,
plugin_version: "local".to_string(),
installed_path: tmp.path().join("plugins/cache/market/manifest-name/local"),
}
@@ -342,10 +326,10 @@ mod tests {
write_plugin(tmp.path(), "source-dir", "../../etc");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(PluginInstallRequest {
source_path: tmp.path().join("source-dir"),
marketplace_name: None,
})
.install(
tmp.path().join("source-dir"),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
@@ -356,19 +340,29 @@ mod tests {
#[test]
fn install_rejects_marketplace_names_with_path_separators() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "sample-plugin");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(PluginInstallRequest {
source_path: tmp.path().join("source-dir"),
marketplace_name: Some("../../etc".to_string()),
})
.unwrap_err();
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
assert_eq!(
err.to_string(),
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
);
}
#[test]
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
tmp.path().join("source-dir"),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
);
}
}