fix: harden plugin feature gating (#15104)

Resubmit https://github.com/openai/codex/pull/15020 with correct
content.

1. Use requirement-resolved config.features as the plugin gate.
2. Guard plugin/list, plugin/read, and related flows behind that gate.
3. Skip bad marketplace.json files instead of failing the whole list.
4. Simplify plugin state and caching.
This commit is contained in:
xl-openai
2026-03-18 17:03:37 -07:00
committed by GitHub
Unverified
parent 56d0c6bf67
commit dcd5e08269
13 changed files with 337 additions and 108 deletions
+131 -18
View File
@@ -59,18 +59,8 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome {
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).unwrap(),
},
toml::from_str(config_toml).expect("plugin test config should parse"),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack should build");
PluginsManager::new(codex_home.to_path_buf()).plugins_for_layer_stack(codex_home, &stack, false)
let config = load_config_blocking(codex_home, codex_home);
PluginsManager::new(codex_home.to_path_buf()).plugins_for_config(&config)
}
async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config {
@@ -82,6 +72,14 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config {
.expect("config should load")
}
fn load_config_blocking(codex_home: &Path, cwd: &Path) -> crate::config::Config {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime should build")
.block_on(load_config(codex_home, cwd))
}
#[test]
fn load_plugins_loads_default_skills_and_mcp_servers() {
let codex_home = TempDir::new().unwrap();
@@ -749,8 +747,13 @@ fn load_plugins_returns_empty_when_feature_disabled() {
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml(true, false),
);
let outcome = load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path());
let config = load_config_blocking(codex_home.path(), codex_home.path());
let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_config(&config);
assert_eq!(outcome, PluginLoadOutcome::default());
}
@@ -1000,12 +1003,125 @@ enabled = false
);
}
#[tokio::test]
async fn list_marketplaces_returns_empty_when_feature_disabled() {
let tmp = tempfile::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": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
[plugins."enabled-plugin@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap();
assert_eq!(marketplaces, Vec::new());
}
#[tokio::test]
async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() {
let tmp = tempfile::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();
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap();
fs::write(
marketplace_path.as_path(),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
[plugins."enabled-plugin@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let err = PluginsManager::new(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "enabled-plugin".to_string(),
marketplace_path,
},
)
.unwrap_err();
assert!(matches!(err, MarketplaceError::PluginsDisabled));
}
#[tokio::test]
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let config = load_config(tmp.path(), tmp.path()).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.sync_plugins_from_remote(&config, None)
.await
.unwrap();
assert_eq!(outcome, RemotePluginSyncResult::default());
}
#[tokio::test]
async fn list_marketplaces_includes_curated_repo_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
let plugin_root = curated_root.join("plugins/linear");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
@@ -1723,11 +1839,8 @@ fn load_plugins_ignores_project_config_files() {
)
.expect("config layer stack should build");
let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_layer_stack(
&project_root,
&stack,
false,
);
let outcome =
load_plugins_from_layer_stack(&stack, &PluginStore::new(codex_home.path().to_path_buf()));
assert_eq!(outcome, PluginLoadOutcome::default());
}