mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[plugins] Enforce marketplace source policy at runtime (#29691)
## Summary - project effective marketplace/plugin config through the enterprise source policy so blocked installed plugins become inactive - filter plugin list/read/discovery and CLI marketplace source/snapshot reporting using the same policy - enforce source admission for background marketplace cache refreshes - continue refreshing/upgrading independent marketplaces and plugins when one entry fails, returning per-entry errors - include policy-projected plugin state in cache and refresh keys so requirement changes invalidate stale results ## Stack This is PR 2 of 2 and is based on #29690. Review the admission model and source matcher in #29690 first; this PR contains only runtime enforcement. ## Test plan - `just test -p codex-core-plugins` (287 tests) - `just test -p codex-cli plugin_list_ignores_implicit_system_marketplace_roots_without_manifests` - `cargo check -p codex-cli -p codex-app-server --tests`
This commit is contained in:
1 parent
e2398d0b16
commit
9dbdb4e2c0
12 files changed
+897
-144
No files matched your search
@@ -100,6 +100,19 @@ fn config_layer_stack_with_requirements(
|
||||
.expect("build config layer stack")
|
||||
}
|
||||
|
||||
fn plugins_config_input_with_requirements(
|
||||
codex_home: &Path,
|
||||
user_config: &str,
|
||||
requirements: &str,
|
||||
) -> PluginsConfigInput {
|
||||
PluginsConfigInput::new(
|
||||
config_layer_stack_with_requirements(codex_home, user_config, requirements),
|
||||
/*plugins_enabled*/ true,
|
||||
/*remote_plugin_enabled*/ false,
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_manager_tracks_auth_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -122,6 +135,184 @@ fn plugins_manager_tracks_auth_mode() {
|
||||
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() {
|
||||
let codex_home = TempDir::new().expect("create Codex home");
|
||||
write_plugin(
|
||||
&codex_home.path().join("plugins/cache/company"),
|
||||
"sample/local",
|
||||
"sample",
|
||||
);
|
||||
let user_config = r#"
|
||||
[marketplaces.company]
|
||||
source_type = "git"
|
||||
source = "https://github.com/example/company.git"
|
||||
|
||||
[plugins."sample@company"]
|
||||
enabled = true
|
||||
"#;
|
||||
let allowed = plugins_config_input_with_requirements(
|
||||
codex_home.path(),
|
||||
user_config,
|
||||
r#"
|
||||
[marketplaces]
|
||||
restrict_to_allowed_sources = true
|
||||
|
||||
[marketplaces.allowed_sources.company]
|
||||
source = "git"
|
||||
url = "https://github.com/example/company.git"
|
||||
"#,
|
||||
);
|
||||
let blocked = plugins_config_input_with_requirements(
|
||||
codex_home.path(),
|
||||
user_config,
|
||||
r#"
|
||||
[marketplaces]
|
||||
restrict_to_allowed_sources = true
|
||||
|
||||
[marketplaces.allowed_sources.other]
|
||||
source = "git"
|
||||
url = "https://github.com/example/other.git"
|
||||
"#,
|
||||
);
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
|
||||
let allowed_outcome = manager.plugins_for_config(&allowed).await;
|
||||
assert_eq!(allowed_outcome.plugins().len(), 1);
|
||||
assert_eq!(allowed_outcome.plugins()[0].config_name, "sample@company");
|
||||
|
||||
let blocked_outcome = manager.plugins_for_config(&blocked).await;
|
||||
assert_eq!(blocked_outcome, PluginLoadOutcome::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_rejects_marketplace_blocked_by_requirements() {
|
||||
let codex_home = TempDir::new().expect("create Codex home");
|
||||
let marketplace_root = codex_home.path().join("marketplace");
|
||||
write_plugin(&marketplace_root, "sample", "sample");
|
||||
write_file(
|
||||
&marketplace_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "company",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample",
|
||||
"source": {"source": "local", "path": "./sample"}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
let config = plugins_config_input_with_requirements(
|
||||
codex_home.path(),
|
||||
"",
|
||||
r#"
|
||||
[marketplaces]
|
||||
restrict_to_allowed_sources = true
|
||||
"#,
|
||||
);
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
|
||||
.expect("absolute marketplace path");
|
||||
|
||||
let err = PluginsManager::new(codex_home.path().to_path_buf())
|
||||
.read_plugin_for_config(
|
||||
&config,
|
||||
&PluginReadRequest {
|
||||
plugin_name: "sample".to_string(),
|
||||
marketplace_path,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("blocked marketplace should not be readable");
|
||||
assert!(matches!(
|
||||
err,
|
||||
MarketplaceError::InvalidMarketplaceFile { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_policy_filters_discovered_marketplaces_by_configured_name() {
|
||||
let codex_home = TempDir::new().expect("create Codex home");
|
||||
let repo_root = codex_home.path().join("repo");
|
||||
let subdirectory = repo_root.join("worktree/subdirectory");
|
||||
fs::create_dir_all(&subdirectory).expect("create input subdirectory");
|
||||
write_plugin(&repo_root, "sample", "sample");
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "company",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample",
|
||||
"source": {"source": "local", "path": "./sample"}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
init_git_repo(&repo_root);
|
||||
let repo_root = AbsolutePathBuf::try_from(repo_root).expect("absolute repository root");
|
||||
let subdirectory =
|
||||
AbsolutePathBuf::try_from(subdirectory).expect("absolute input subdirectory");
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let user_config = format!(
|
||||
r#"
|
||||
[marketplaces.company]
|
||||
source_type = "local"
|
||||
source = {:?}
|
||||
"#,
|
||||
repo_root.as_path()
|
||||
);
|
||||
let allowed = plugins_config_input_with_requirements(
|
||||
codex_home.path(),
|
||||
&user_config,
|
||||
&format!(
|
||||
r#"
|
||||
[marketplaces]
|
||||
restrict_to_allowed_sources = true
|
||||
|
||||
[marketplaces.allowed_sources.company]
|
||||
source = "local"
|
||||
path = {:?}
|
||||
"#,
|
||||
repo_root.as_path()
|
||||
),
|
||||
);
|
||||
let blocked = plugins_config_input_with_requirements(
|
||||
codex_home.path(),
|
||||
&user_config,
|
||||
&format!(
|
||||
r#"
|
||||
[marketplaces]
|
||||
restrict_to_allowed_sources = true
|
||||
|
||||
[marketplaces.allowed_sources.subdirectory]
|
||||
source = "local"
|
||||
path = {:?}
|
||||
"#,
|
||||
subdirectory.as_path()
|
||||
),
|
||||
);
|
||||
|
||||
let allowed_outcome = manager
|
||||
.list_marketplaces_for_config(
|
||||
&allowed,
|
||||
std::slice::from_ref(&subdirectory),
|
||||
/*include_openai_curated*/ false,
|
||||
)
|
||||
.expect("list allowed marketplace");
|
||||
assert_eq!(allowed_outcome.marketplaces.len(), 1);
|
||||
assert_eq!(allowed_outcome.marketplaces[0].name, "company");
|
||||
|
||||
let blocked_outcome = manager
|
||||
.list_marketplaces_for_config(
|
||||
&blocked,
|
||||
std::slice::from_ref(&subdirectory),
|
||||
/*include_openai_curated*/ false,
|
||||
)
|
||||
.expect("list blocked marketplace");
|
||||
assert_eq!(blocked_outcome.marketplaces, Vec::new());
|
||||
}
|
||||
|
||||
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
|
||||
let plugin_root = codex_home
|
||||
.join("plugins/cache")
|
||||
@@ -3348,7 +3539,11 @@ plugins = true
|
||||
.unwrap()
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.find(|marketplace| {
|
||||
marketplace.path
|
||||
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap()
|
||||
})
|
||||
.unwrap()
|
||||
.plugins
|
||||
.into_iter()
|
||||
@@ -3785,7 +3980,7 @@ enabled = true
|
||||
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
/*include_openai_curated*/ true,
|
||||
)
|
||||
.unwrap()
|
||||
@@ -3793,7 +3988,11 @@ enabled = true
|
||||
|
||||
let marketplace = marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.find(|marketplace| {
|
||||
marketplace.path
|
||||
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap()
|
||||
})
|
||||
.expect("debug marketplace should be listed");
|
||||
|
||||
let mut plugins = marketplace.plugins;
|
||||
@@ -5256,6 +5455,7 @@ enabled = true
|
||||
refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should succeed")
|
||||
);
|
||||
@@ -5308,6 +5508,7 @@ enabled = true
|
||||
refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should reinstall missing configured plugin")
|
||||
);
|
||||
@@ -5367,6 +5568,7 @@ enabled = true
|
||||
refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should materialize configured Git plugin")
|
||||
);
|
||||
@@ -5420,6 +5622,7 @@ enabled = true
|
||||
!refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should be a no-op when configured plugins are current")
|
||||
);
|
||||
@@ -5473,6 +5676,7 @@ enabled = true
|
||||
refresh_non_curated_plugin_cache_force_reinstall(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should reinstall unchanged local version")
|
||||
);
|
||||
@@ -5531,6 +5735,7 @@ enabled = true
|
||||
refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["sample-plugin@debug".to_string()],
|
||||
)
|
||||
.expect("cache refresh should ignore unrelated invalid plugin manifests")
|
||||
);
|
||||
@@ -5542,6 +5747,47 @@ enabled = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_non_curated_plugin_cache_continues_after_plugin_error() {
|
||||
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();
|
||||
write_plugin_with_version(&repo_root, "z-good", "z-good", Some("1.2.3"));
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "a-broken",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./missing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "z-good",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./z-good"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
|
||||
let err = refresh_non_curated_plugin_cache(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
&["a-broken@debug".to_string(), "z-good@debug".to_string()],
|
||||
)
|
||||
.expect_err("broken plugin should be reported after refreshing the remaining plugins");
|
||||
|
||||
assert!(err.contains("a-broken@debug"));
|
||||
assert!(tmp.path().join("plugins/cache/debug/z-good/1.2.3").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_plugins_ignores_project_config_files() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
Reference in new issue
Block a user