[codex] Add cross-repo plugin sources to marketplace manifests (#18017)

## Summary
- add first-class marketplace support for git-backed plugin sources
- keep the newer marketplace parsing behavior from `main`, including
alternate manifest locations and string local sources
- materialize remote plugin sources during install, detail reads, and
non-curated cache refresh
- expose git plugin source metadata through the app-server protocol

## Details
This teaches the marketplace parser to accept all of the following:
- local string sources such as `"source": "./plugins/foo"`
- local object sources such as
`{"source":"local","path":"./plugins/foo"}`
- remote repo-root sources such as
`{"source":"url","url":"https://github.com/org/repo.git"}`
- remote subdir sources such as
`{"source":"git-subdir","url":"owner/repo","path":"plugins/foo","ref":"main","sha":"..."}`

It also preserves the newer tolerant behavior from `main`: invalid or
unsupported plugin entries are skipped instead of breaking the whole
marketplace.

## Validation
- `cargo test -p codex-core plugins::marketplace::tests`
- `just fix -p codex-core`
- `just fmt`

## Notes
- A full `cargo test -p codex-core` run still hit unrelated existing
failures in agent and multi-agent tests during this session; the
marketplace-focused suite passed after the rebase resolution.
This commit is contained in:
xli-oai
2026-04-17 15:11:42 -07:00
committed by GitHub
Unverified
parent 1265df0ec2
commit 0e111e08d0
13 changed files with 1336 additions and 113 deletions
+80 -5
View File
@@ -22,6 +22,7 @@ use codex_core_plugins::loader::load_plugin_mcp_servers;
use codex_core_plugins::loader::load_plugin_skills;
use codex_core_plugins::loader::load_plugins_from_layer_stack;
use codex_core_plugins::loader::log_plugin_load_errors;
use codex_core_plugins::loader::materialize_marketplace_plugin_source;
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
use codex_core_plugins::loader::refresh_curated_plugin_cache;
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
@@ -39,6 +40,7 @@ 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::plugin_interface_with_marketplace_category;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
@@ -188,6 +190,12 @@ pub struct PluginDetail {
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
pub apps: Vec<AppConnectorId>,
pub mcp_server_names: Vec<String>,
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginDetailsUnavailableReason {
InstallRequiredForRemoteSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -588,8 +596,12 @@ impl PluginsManager {
None
};
let store = self.store.clone();
let codex_home = self.codex_home.clone();
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
let MarketplacePluginSource::Local { path: source_path } = resolved.source;
let materialized =
materialize_marketplace_plugin_source(codex_home.as_path(), &resolved.source)
.map_err(PluginStoreError::Invalid)?;
let source_path = materialized.path;
if let Some(plugin_version) = plugin_version {
store.install_with_version(source_path, resolved.plugin_id, plugin_version)
} else {
@@ -754,6 +766,14 @@ impl PluginsManager {
let plugin_key = plugin_id.as_key();
let source_path = match plugin.source {
MarketplacePluginSource::Local { path } => path,
MarketplacePluginSource::Git { .. } => {
warn!(
plugin = plugin_name,
marketplace = %marketplace_name,
"skipping remote plugin source during remote sync"
);
continue;
}
};
let current_enabled = configured_plugins
.get(&plugin_key)
@@ -1032,9 +1052,55 @@ impl PluginsManager {
});
}
let source_path = match &plugin.source {
MarketplacePluginSource::Local { path } => path.clone(),
};
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();
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && !plugin.installed {
return Ok(PluginDetail {
id: plugin_key,
name: plugin.name,
description: None,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
installed: plugin.installed,
enabled: plugin.enabled,
skills: Vec::new(),
disabled_skill_paths: HashSet::new(),
apps: Vec::new(),
mcp_server_names: Vec::new(),
details_unavailable_reason: Some(
PluginDetailsUnavailableReason::InstallRequiredForRemoteSource,
),
});
}
let source_path =
if matches!(plugin.source, MarketplacePluginSource::Git { .. }) && plugin.installed {
self.store.active_plugin_root(&plugin_id).ok_or_else(|| {
MarketplaceError::InvalidPlugin(format!(
"installed plugin cache entry is missing for {plugin_key}"
))
})?
} else {
let codex_home = self.codex_home.clone();
let source = plugin.source.clone();
let materialized = tokio::task::spawn_blocking(move || {
materialize_marketplace_plugin_source(codex_home.as_path(), &source)
})
.await
.map_err(|err| {
MarketplaceError::InvalidPlugin(format!(
"failed to materialize plugin source: {err}"
))
})?
.map_err(MarketplaceError::InvalidPlugin)?;
materialized.path.clone()
};
if !source_path.as_path().is_dir() {
return Err(MarketplaceError::InvalidPlugin(
"path does not exist or is not a directory".to_string(),
@@ -1044,6 +1110,14 @@ impl PluginsManager {
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
})?;
let description = manifest.description.clone();
let marketplace_category = plugin
.interface
.as_ref()
.and_then(|interface| interface.category.clone());
let interface = plugin_interface_with_marketplace_category(
manifest.interface.clone(),
marketplace_category,
);
let resolved_skills = load_plugin_skills(
&source_path,
&manifest.paths,
@@ -1067,13 +1141,14 @@ impl PluginsManager {
description,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
interface,
installed: plugin.installed,
enabled: plugin.enabled,
skills: resolved_skills.skills,
disabled_skill_paths: resolved_skills.disabled_skill_paths,
apps,
mcp_server_names,
details_unavailable_reason: None,
})
}
+364
View File
@@ -66,6 +66,31 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
);
}
fn init_git_repo(repo: &Path) {
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "codex-test@example.com"]);
run_git(repo, &["config", "user.name", "Codex Test"]);
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "initial"]);
}
fn run_git(repo: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap_or_else(|err| panic!("git should run: {err}"));
assert!(
output.status.success(),
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
repo.display(),
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
@@ -1050,6 +1075,113 @@ async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
);
}
#[tokio::test]
async fn install_plugin_supports_git_subdir_marketplace_sources() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("marketplace");
let remote_repo = tmp.path().join("remote-plugin-repo");
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
init_git_repo(&remote_repo);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "toolkit",
"source": {{
"source": "git-subdir",
"url": "{remote_repo_url}",
"path": "plugins/toolkit"
}}
}}
]
}}"#
),
)
.unwrap();
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[tokio::test]
async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("marketplace");
let remote_repo = repo_root.join("remote-plugin-repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
init_git_repo(&remote_repo);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "toolkit",
"source": {
"source": "git-subdir",
"url": "./remote-plugin-repo",
"path": "plugins/toolkit"
}
}
]
}"#,
)
.unwrap();
let result = PluginsManager::new(tmp.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
})
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[tokio::test]
async fn uninstall_plugin_removes_cache_and_config_entry() {
let tmp = tempfile::tempdir().unwrap();
@@ -1433,6 +1565,179 @@ enabled = false
assert!(outcome.plugin.disabled_skill_paths.is_empty());
}
#[tokio::test]
async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "toolkit",
"source": {{
"source": "git-subdir",
"url": "{missing_remote_repo_url}",
"path": "plugins/toolkit"
}},
"policy": {{
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
}}
}}
]
}}"#
),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(
outcome.plugin.details_unavailable_reason,
Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource)
);
assert!(!outcome.plugin.installed);
assert!(outcome.plugin.description.is_none());
assert!(outcome.plugin.skills.is_empty());
assert!(outcome.plugin.apps.is_empty());
assert!(outcome.plugin.mcp_server_names.is_empty());
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
}
#[tokio::test]
async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "toolkit",
"source": {{
"source": "git-subdir",
"url": "{missing_remote_repo_url}",
"path": "plugins/toolkit"
}},
"category": "Developer Tools"
}}
]
}}"#
),
);
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
write_file(
&cached_plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "toolkit",
"description": "Cached toolkit plugin",
"interface": {
"displayName": "Toolkit"
}
}"#,
);
write_file(
&cached_plugin_root.join("skills/search/SKILL.md"),
"---\nname: search\ndescription: search cached data\n---\n",
);
write_file(
&cached_plugin_root.join(".app.json"),
r#"{"apps":{"calendar":{"id":"connector_calendar"}}}"#,
);
write_file(
&cached_plugin_root.join(".mcp.json"),
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."toolkit@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(outcome.plugin.details_unavailable_reason, None);
assert_eq!(
outcome.plugin.description.as_deref(),
Some("Cached toolkit plugin")
);
assert_eq!(
outcome.plugin.interface,
Some(PluginManifestInterface {
display_name: Some("Toolkit".to_string()),
category: Some("Developer Tools".to_string()),
..Default::default()
})
);
assert!(outcome.plugin.installed);
assert_eq!(outcome.plugin.skills.len(), 1);
assert_eq!(outcome.plugin.skills[0].name, "toolkit:search");
assert_eq!(
outcome.plugin.apps,
vec![AppConnectorId("connector_calendar".to_string())]
);
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
}
#[tokio::test]
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
@@ -2656,6 +2961,65 @@ enabled = true
);
}
#[test]
fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let remote_repo = tmp.path().join("remote-plugin-repo");
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_plugin_with_version(
&remote_repo,
"plugins/sample-plugin",
"sample-plugin",
Some("1.2.3"),
);
init_git_repo(&remote_repo);
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "sample-plugin",
"source": {{
"source": "git-subdir",
"url": "{remote_repo_url}",
"path": "plugins/sample-plugin"
}}
}}
]
}}"#
),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
)
.expect("cache refresh should materialize configured Git plugin")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
+1
View File
@@ -35,6 +35,7 @@ pub use manager::ConfiguredMarketplacePlugin;
pub use manager::OPENAI_BUNDLED_MARKETPLACE_NAME;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginDetail;
pub use manager::PluginDetailsUnavailableReason;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;