feat: support product-scoped plugins. (#15041)

1. Added SessionSource::Custom(String) and --session-source.
  2. Enforced plugin and skill products by session_source.
  3. Applied the same filtering to curated background refresh.
This commit is contained in:
xl-openai
2026-03-19 00:46:15 -07:00
committed by GitHub
Unverified
parent 01df50cf42
commit db5781a088
35 changed files with 652 additions and 38 deletions
+61 -5
View File
@@ -42,6 +42,7 @@ use crate::skills::loader::SkillRoot;
use crate::skills::loader::load_skills_from_roots;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::MergeStrategy;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
@@ -461,16 +462,32 @@ pub struct PluginsManager {
store: PluginStore,
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
cached_enabled_outcome: RwLock<Option<PluginLoadOutcome>>,
restriction_product: Option<Product>,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
impl PluginsManager {
pub fn new(codex_home: PathBuf) -> Self {
Self::new_with_restriction_product(codex_home, Some(Product::Codex))
}
pub fn new_with_restriction_product(
codex_home: PathBuf,
restriction_product: Option<Product>,
) -> Self {
// Product restrictions are enforced at marketplace admission time for a given CODEX_HOME:
// listing, install, and curated refresh all consult this restriction context before new
// plugins enter local config or cache. After admission, runtime plugin loading trusts the
// contents of that CODEX_HOME and does not re-filter configured plugins by product, so
// already-admitted plugins may continue exposing MCP servers/tools from shared local state.
//
// This assumes a single CODEX_HOME is only used by one product.
Self {
codex_home: codex_home.clone(),
store: PluginStore::new(codex_home),
featured_plugin_ids_cache: RwLock::new(None),
cached_enabled_outcome: RwLock::new(None),
restriction_product,
analytics_events_client: RwLock::new(None),
}
}
@@ -483,6 +500,13 @@ impl PluginsManager {
*stored_client = Some(analytics_events_client);
}
fn restriction_product_matches(&self, products: &[Product]) -> bool {
products.is_empty()
|| self
.restriction_product
.is_some_and(|product| product.matches_product_restriction(products))
}
pub fn plugins_for_config(&self, config: &Config) -> PluginLoadOutcome {
self.plugins_for_config_with_force_reload(config, /*force_reload*/ false)
}
@@ -600,7 +624,11 @@ impl PluginsManager {
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
let resolved = resolve_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
)?;
self.install_resolved_plugin(resolved).await
}
@@ -610,7 +638,11 @@ impl PluginsManager {
auth: Option<&CodexAuth>,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
let resolved = resolve_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
)?;
let plugin_id = resolved.plugin_id.as_key();
// This only forwards the backend mutation before the local install flow. We rely on
// `plugin/list(forceRemoteSync=true)` to sync local state rather than doing an extra
@@ -775,6 +807,7 @@ impl PluginsManager {
AbsolutePathBuf,
Option<bool>,
Option<String>,
bool,
)>::new();
let mut local_plugin_names = HashSet::new();
for plugin in curated_marketplace.plugins {
@@ -797,12 +830,14 @@ impl PluginsManager {
.get(&plugin_key)
.map(|plugin| plugin.enabled);
let installed_version = self.store.active_plugin_version(&plugin_id);
let product_allowed = self.restriction_product_matches(&plugin.policy.products);
local_plugins.push((
plugin_name,
plugin_id,
source_path,
current_enabled,
installed_version,
product_allowed,
));
}
@@ -841,11 +876,20 @@ impl PluginsManager {
let remote_plugin_count = remote_installed_plugin_names.len();
let local_plugin_count = local_plugins.len();
for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in
local_plugins
for (
plugin_name,
plugin_id,
source_path,
current_enabled,
installed_version,
product_allowed,
) in local_plugins
{
let plugin_key = plugin_id.as_key();
let is_installed = installed_version.is_some();
if !product_allowed {
continue;
}
if remote_installed_plugin_names.contains(&plugin_name) {
if !is_installed {
installs.push((
@@ -947,6 +991,9 @@ impl PluginsManager {
if !seen_plugin_keys.insert(plugin_key.clone()) {
return None;
}
if !self.restriction_product_matches(&plugin.policy.products) {
return None;
}
Some(ConfiguredMarketplacePlugin {
// Enabled state is keyed by `<plugin>@<marketplace>`, so duplicate
@@ -994,6 +1041,12 @@ impl PluginsManager {
marketplace_name,
});
};
if !self.restriction_product_matches(&plugin.policy.products) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: request.plugin_name.clone(),
marketplace_name,
});
}
let plugin_id = PluginId::new(plugin.name.clone(), marketplace.name.clone()).map_err(
|err| match err {
@@ -1017,7 +1070,10 @@ impl PluginsManager {
path,
scope: SkillScope::User,
}))
.skills;
.skills
.into_iter()
.filter(|skill| skill.matches_product_restriction_for_product(self.restriction_product))
.collect();
let apps = load_plugin_apps(source_path.as_path());
let mcp_config_paths = plugin_mcp_config_paths(source_path.as_path(), manifest_paths);
let mut mcp_server_names = Vec::new();
+5 -1
View File
@@ -146,6 +146,7 @@ impl MarketplaceError {
pub fn resolve_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;
@@ -168,7 +169,10 @@ pub fn resolve_marketplace_plugin(
..
} = plugin;
let install_policy = policy.installation;
if install_policy == MarketplacePluginInstallPolicy::NotAvailable {
let product_allowed = policy.products.is_empty()
|| restriction_product
.is_some_and(|product| product.matches_product_restriction(&policy.products));
if install_policy == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed {
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: name,
marketplace_name,
@@ -30,6 +30,7 @@ fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
@@ -59,6 +60,7 @@ fn resolve_marketplace_plugin_reports_missing_plugin() {
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"missing",
Some(Product::Codex),
)
.unwrap_err();
@@ -297,6 +299,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() {
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
@@ -687,6 +690,7 @@ fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap_err();
@@ -732,6 +736,7 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
let resolved = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"local-plugin",
Some(Product::Codex),
)
.unwrap();
@@ -740,3 +745,42 @@ fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
);
}
#[test]
fn resolve_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();
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": "chatgpt-plugin",
"source": {
"source": "local",
"path": "./plugin"
},
"policy": {
"products": ["CHATGPT"]
}
}
]
}"#,
)
.unwrap();
let err = resolve_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"chatgpt-plugin",
Some(Product::Atlas),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"plugin `chatgpt-plugin` is not available for install in marketplace `codex-curated`"
);
}