Compare commits

...

6 Commits

Author SHA1 Message Date
viyatb-oai
c7e781825b feat: enforce managed plugin entrypoints
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:57:49 -07:00
viyatb-oai
106296cb8d fix: keep plugin allowlist PR independently buildable
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:57:38 -07:00
viyatb-oai
641f65a858 feat: enforce managed plugin allowlists
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:35:23 -07:00
viyatb-oai
5e4c73cd78 feat: enforce managed skill requirements
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:35:23 -07:00
viyatb-oai
4825e48fcd fix: keep managed artifact sources in config plumbing
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:35:13 -07:00
viyatb-oai
45c47d2b33 feat: add managed artifact requirement plumbing
Co-authored-by: Codex noreply@openai.com
2026-05-06 19:19:09 -07:00
16 changed files with 889 additions and 34 deletions

View File

@@ -6,7 +6,7 @@ use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_add::add_marketplace_for_config;
use codex_core_plugins::marketplace_add::is_local_marketplace_source;
use codex_external_agent_migration::build_mcp_config_from_external;
use codex_external_agent_migration::count_missing_commands;
@@ -701,6 +701,35 @@ impl ExternalAgentConfigService {
};
let mut outcome = PluginImportOutcome::default();
let plugins_manager = PluginsManager::new(self.codex_home.clone());
let config = ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.fallback_cwd(Some(self.codex_home.clone()))
.build()
.await
.map_err(|err| {
invalid_data_error(format!("failed to load config before plugin import: {err}"))
})?;
let plugin_marketplace_requirements = config
.config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref();
if plugin_marketplace_requirements
.is_some_and(|requirements| !requirements.value.allows_user_additions())
{
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name;
outcome.failed_plugin_ids.extend(
plugin_group
.plugin_names
.into_iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}")),
);
outcome.failed_marketplaces.push(marketplace_name);
}
return Ok(outcome);
}
let plugins_input = config.plugins_config_input();
for plugin_group in plugins {
let marketplace_name = plugin_group.marketplace_name.clone();
let plugin_names = plugin_group.plugin_names;
@@ -708,6 +737,13 @@ impl ExternalAgentConfigService {
.iter()
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
.collect::<Vec<_>>();
if plugin_marketplace_requirements.is_some_and(|requirements| {
!requirements.value.allows_marketplace(&marketplace_name)
}) {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
continue;
}
let source_settings = cwd.map_or_else(
|| self.external_agent_home.join("settings.json"),
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
@@ -728,7 +764,12 @@ impl ExternalAgentConfigService {
ref_name: import_source.ref_name,
sparse_paths: Vec::new(),
};
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
let add_marketplace_outcome = add_marketplace_for_config(
&config.config_layer_stack,
self.codex_home.clone(),
request,
)
.await;
let marketplace_path = match add_marketplace_outcome {
Ok(add_marketplace_outcome) => {
let Some(marketplace_path) = find_marketplace_manifest_path(
@@ -751,10 +792,13 @@ impl ExternalAgentConfigService {
};
for plugin_name in plugin_names {
match plugins_manager
.install_plugin(PluginInstallRequest {
plugin_name: plugin_name.clone(),
marketplace_path: marketplace_path.clone(),
})
.install_plugin_for_config(
&plugins_input,
PluginInstallRequest {
plugin_name: plugin_name.clone(),
marketplace_path: marketplace_path.clone(),
},
)
.await
{
Ok(_) => outcome

View File

@@ -299,7 +299,7 @@ use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace_add::MarketplaceAddError;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home;
use codex_core_plugins::marketplace_add::add_marketplace_for_config as add_marketplace_to_codex_home;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveError;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;

View File

@@ -105,7 +105,9 @@ impl MarketplaceRequestProcessor {
&self,
params: MarketplaceAddParams,
) -> Result<MarketplaceAddResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
add_marketplace_to_codex_home(
&config.config_layer_stack,
self.config.codex_home.to_path_buf(),
MarketplaceAddRequest {
source: params.source,

View File

@@ -175,6 +175,15 @@ fn plugin_share_principal_from_remote(
}
}
fn remote_marketplace_is_allowed(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.is_none_or(|requirements| requirements.value.allows_marketplace(marketplace_name))
}
impl PluginRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
@@ -485,6 +494,9 @@ impl PluginRequestProcessor {
Ok(remote_marketplaces) => {
for remote_marketplace in remote_marketplaces
.into_iter()
.filter(|marketplace| {
remote_marketplace_is_allowed(&config, &marketplace.name)
})
.map(remote_marketplace_to_info)
{
if let Some(existing) = data
@@ -620,6 +632,11 @@ impl PluginRequestProcessor {
}
}
Err(remote_marketplace_name) => {
if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {remote_marketplace_name} is not allowed by managed requirements"
)));
}
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
@@ -640,6 +657,12 @@ impl PluginRequestProcessor {
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details")
})?;
if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {} is not allowed by managed requirements",
remote_detail.marketplace_name
)));
}
let plugin_apps = remote_detail
.app_ids
.iter()
@@ -667,6 +690,11 @@ impl PluginRequestProcessor {
} = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {remote_marketplace_name} is not allowed by managed requirements"
)));
}
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
@@ -683,6 +711,20 @@ impl PluginRequestProcessor {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details"))?;
if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {} is not allowed by managed requirements",
remote_detail.marketplace_name
)));
}
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
&remote_plugin_service_config,
auth.as_ref(),
@@ -888,13 +930,14 @@ impl PluginRequestProcessor {
}
let plugins_manager = self.thread_manager.plugins_manager();
let plugins_input = config.plugins_config_input();
let request = PluginInstallRequest {
plugin_name,
marketplace_path,
};
let result = plugins_manager
.install_plugin(request)
.install_plugin_for_config(&plugins_input, request)
.await
.map_err(Self::plugin_install_error)?;
let config = match self.load_latest_config(config_cwd).await {
@@ -938,6 +981,11 @@ impl PluginRequestProcessor {
remote_plugin_id: String,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !remote_marketplace_is_allowed(&config, &remote_marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {remote_marketplace_name} is not allowed by managed requirements"
)));
}
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
@@ -963,6 +1011,12 @@ impl PluginRequestProcessor {
"read remote plugin details before install",
)
})?;
if !remote_marketplace_is_allowed(&config, &remote_detail.marketplace_name) {
return Err(invalid_request(format!(
"remote marketplace {} is not allowed by managed requirements",
remote_detail.marketplace_name
)));
}
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
let remote_plugin_id = &remote_detail.summary.id;
return Err(invalid_request(format!(
@@ -1275,6 +1329,7 @@ impl PluginRequestProcessor {
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::PluginsDisabled
| MarketplaceError::MarketplaceBlocked { .. }
| MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()),
MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")),
}

View File

@@ -487,6 +487,46 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable
"plugin_release_skill_id": "skill-1",
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
}"##;
let detail_body = r#"{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"keywords": [],
"interface": {},
"skills": []
}
}"#;
let installed_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
Mock::given(method("GET"))
.and(path(
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",
))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(

View File

@@ -7,7 +7,7 @@ use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_add::add_marketplace_for_config;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_utils_cli::CliConfigOverrides;
@@ -72,7 +72,7 @@ impl MarketplaceCli {
.map_err(anyhow::Error::msg)?;
match subcommand {
MarketplaceSubcommand::Add(args) => run_add(args).await?,
MarketplaceSubcommand::Add(args) => run_add(overrides, args).await?,
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
}
@@ -81,15 +81,19 @@ impl MarketplaceCli {
}
}
async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
async fn run_add(overrides: Vec<(String, toml::Value)>, args: AddMarketplaceArgs) -> Result<()> {
let AddMarketplaceArgs {
source,
ref_name,
sparse_paths,
} = args;
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let outcome = add_marketplace(
let outcome = add_marketplace_for_config(
&config.config_layer_stack,
codex_home.to_path_buf(),
MarketplaceAddRequest {
source,

View File

@@ -90,6 +90,8 @@ pub struct ConfigRequirements {
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub skills: Option<Sourced<SkillsRequirementsToml>>,
pub plugin_marketplaces: Option<Sourced<PluginMarketplaceRequirementsToml>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
/// Managed network constraints derived from requirements.
@@ -123,6 +125,8 @@ impl Default for ConfigRequirements {
managed_hooks: None,
mcp_servers: None,
plugins: None,
skills: None,
plugin_marketplaces: None,
exec_policy: None,
enforce_residency: ConstrainedWithSource::new(
Constrained::allow_any(/*initial_value*/ None),
@@ -164,6 +168,57 @@ impl PluginRequirementsToml {
}
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum SkillSourceRequirement {
User,
Repo,
System,
Admin,
Plugin,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct SkillsRequirementsToml {
pub allowed_sources: Option<Vec<SkillSourceRequirement>>,
}
impl SkillsRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allowed_sources.is_none()
}
pub fn allows_source(&self, source: SkillSourceRequirement) -> bool {
self.allowed_sources
.as_ref()
.is_none_or(|sources| sources.contains(&source))
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginMarketplaceRequirementsToml {
pub allowed_names: Option<Vec<String>>,
pub allow_user_additions: Option<bool>,
}
impl PluginMarketplaceRequirementsToml {
pub fn is_empty(&self) -> bool {
self.allowed_names.is_none() && self.allow_user_additions.is_none()
}
pub fn allows_marketplace(&self, marketplace_name: &str) -> bool {
self.allowed_names.as_ref().is_none_or(|allowed_names| {
allowed_names
.iter()
.any(|allowed_name| allowed_name == marketplace_name)
})
}
pub fn allows_user_additions(&self) -> bool {
self.allow_user_additions.unwrap_or(true)
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct NetworkDomainPermissionsToml {
#[serde(flatten)]
@@ -694,6 +749,8 @@ pub struct ConfigRequirementsWithSources {
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub plugins: Option<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
pub skills: Option<Sourced<SkillsRequirementsToml>>,
pub plugin_marketplaces: Option<Sourced<PluginMarketplaceRequirementsToml>>,
pub apps: Option<Sourced<AppsRequirementsToml>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
@@ -786,6 +843,8 @@ impl ConfigRequirementsWithSources {
hooks,
mcp_servers,
plugins,
skills: _,
plugin_marketplaces: _,
apps,
rules,
enforce_residency,
@@ -923,6 +982,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
hooks,
mcp_servers,
plugins,
skills,
plugin_marketplaces,
apps: _apps,
rules,
enforce_residency,
@@ -1158,6 +1219,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
managed_hooks,
mcp_servers,
plugins,
skills,
plugin_marketplaces,
exec_policy,
enforce_residency,
network,
@@ -1251,6 +1314,8 @@ mod tests {
hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
plugins: plugins.map(|value| Sourced::new(value, RequirementSource::Unknown)),
skills: None,
plugin_marketplaces: None,
apps: apps.map(|value| Sourced::new(value, RequirementSource::Unknown)),
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
enforce_residency: enforce_residency
@@ -1330,6 +1395,8 @@ mod tests {
hooks: None,
mcp_servers: None,
plugins: None,
skills: None,
plugin_marketplaces: None,
apps: None,
rules: None,
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
@@ -1369,6 +1436,8 @@ mod tests {
hooks: None,
mcp_servers: None,
plugins: None,
skills: None,
plugin_marketplaces: None,
apps: None,
rules: None,
enforce_residency: None,
@@ -1416,6 +1485,8 @@ mod tests {
hooks: None,
mcp_servers: None,
plugins: None,
skills: None,
plugin_marketplaces: None,
apps: None,
rules: None,
enforce_residency: None,

View File

@@ -49,11 +49,14 @@ pub use config_requirements::NetworkDomainPermissionsToml;
pub use config_requirements::NetworkRequirementsToml;
pub use config_requirements::NetworkUnixSocketPermissionToml;
pub use config_requirements::NetworkUnixSocketPermissionsToml;
pub use config_requirements::PluginMarketplaceRequirementsToml;
pub use config_requirements::PluginRequirementsToml;
pub use config_requirements::RemoteSandboxConfigToml;
pub use config_requirements::RequirementSource;
pub use config_requirements::ResidencyRequirement;
pub use config_requirements::SandboxModeRequirement;
pub use config_requirements::SkillSourceRequirement;
pub use config_requirements::SkillsRequirementsToml;
pub use config_requirements::Sourced;
pub use config_requirements::WebSearchModeRequirement;
pub use config_requirements::sandbox_mode_requirement_for_permission_profile;

View File

@@ -1,4 +1,5 @@
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::manager::configured_plugins_for_stack;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
@@ -378,10 +379,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
fn configured_plugins_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return HashMap::new();
};
configured_plugins_from_user_config_value(&user_layer.config)
configured_plugins_for_stack(config_layer_stack)
}
fn is_full_git_sha(value: &str) -> bool {

View File

@@ -48,6 +48,7 @@ use crate::store::PluginStoreError;
use codex_analytics::AnalyticsEventsClient;
use codex_config::ConfigLayerStack;
use codex_config::PluginConfigEdit;
use codex_config::PluginMarketplaceRequirementsToml;
use codex_config::apply_user_plugin_config_edits;
use codex_config::clear_user_plugin;
use codex_config::set_user_plugin_enabled;
@@ -401,6 +402,7 @@ pub struct PluginsManager {
struct CachedPluginLoadOutcome {
config_version: String,
plugin_hooks_enabled: bool,
plugin_marketplace_requirements: Option<PluginMarketplaceRequirementsToml>,
outcome: PluginLoadOutcome,
}
@@ -473,9 +475,13 @@ impl PluginsManager {
let plugin_hooks_enabled = config.plugin_hooks_enabled;
let config_version = version_for_toml(&config.config_layer_stack.effective_config());
let plugin_marketplace_requirements = plugin_marketplace_requirements(config);
if !force_reload
&& let Some(outcome) =
self.cached_enabled_outcome(&config_version, plugin_hooks_enabled)
&& let Some(outcome) = self.cached_enabled_outcome(
&config_version,
plugin_hooks_enabled,
plugin_marketplace_requirements.as_ref(),
)
{
return outcome;
}
@@ -496,6 +502,7 @@ impl PluginsManager {
*cache = Some(CachedPluginLoadOutcome {
config_version,
plugin_hooks_enabled,
plugin_marketplace_requirements,
outcome: outcome.clone(),
});
outcome
@@ -553,6 +560,7 @@ impl PluginsManager {
&self,
config_version: &str,
plugin_hooks_enabled: bool,
plugin_marketplace_requirements: Option<&PluginMarketplaceRequirementsToml>,
) -> Option<PluginLoadOutcome> {
match self.cached_enabled_outcome.read() {
Ok(cache) => cache
@@ -560,6 +568,8 @@ impl PluginsManager {
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
&& cached.plugin_marketplace_requirements.as_ref()
== plugin_marketplace_requirements
})
.map(|cached| cached.outcome.clone()),
Err(err) => err
@@ -568,6 +578,8 @@ impl PluginsManager {
.filter(|cached| {
cached.config_version == config_version
&& cached.plugin_hooks_enabled == plugin_hooks_enabled
&& cached.plugin_marketplace_requirements.as_ref()
== plugin_marketplace_requirements
})
.map(|cached| cached.outcome.clone()),
}
@@ -589,7 +601,16 @@ impl PluginsManager {
return HashMap::new();
};
remote_installed_plugins_to_config(plugins, &self.store)
if plugin_marketplace_requirements(config).is_none() {
return remote_installed_plugins_to_config(plugins, &self.store);
}
let plugins = plugins
.iter()
.filter(|plugin| marketplace_is_allowed(config, &plugin.marketplace_name))
.cloned()
.collect::<Vec<_>>();
remote_installed_plugins_to_config(&plugins, &self.store)
}
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
@@ -801,6 +822,25 @@ impl PluginsManager {
self.install_resolved_plugin(resolved).await
}
pub async fn install_plugin_for_config(
&self,
config: &PluginsConfigInput,
request: PluginInstallRequest,
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = find_installable_marketplace_plugin(
&request.marketplace_path,
&request.plugin_name,
self.restriction_product,
)?;
if !marketplace_is_allowed(config, &resolved.plugin_id.marketplace_name) {
return Err(MarketplaceError::MarketplaceBlocked {
marketplace_name: resolved.plugin_id.marketplace_name,
}
.into());
}
self.install_resolved_plugin(resolved).await
}
pub async fn install_plugin_with_remote_sync(
&self,
config: &PluginsConfigInput,
@@ -952,6 +992,9 @@ impl PluginsManager {
if !config.plugins_enabled {
return Ok(RemotePluginSyncResult::default());
}
if !marketplace_is_allowed(config, OPENAI_CURATED_MARKETPLACE_NAME) {
return Ok(RemotePluginSyncResult::default());
}
info!("starting remote plugin sync");
let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status(
@@ -960,7 +1003,7 @@ impl PluginsManager {
)
.await
.map_err(PluginRemoteSyncError::from)?;
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let configured_plugins = configured_plugins_for_config(config);
let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path());
let curated_marketplace_path = AbsolutePathBuf::try_from(
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
@@ -1174,6 +1217,9 @@ impl PluginsManager {
.marketplaces
.into_iter()
.filter_map(|marketplace| {
if !marketplace_is_allowed(config, &marketplace.name) {
return None;
}
let marketplace_name = marketplace.name.clone();
let plugins = marketplace
.plugins
@@ -1228,6 +1274,11 @@ impl PluginsManager {
}
let plugin = find_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
if !marketplace_is_allowed(config, &plugin.plugin_id.marketplace_name) {
return Err(MarketplaceError::MarketplaceBlocked {
marketplace_name: plugin.plugin_id.marketplace_name,
});
}
if !self.restriction_product_matches(plugin.policy.products.as_deref()) {
return Err(MarketplaceError::PluginNotFound {
plugin_name: plugin.plugin_id.plugin_name,
@@ -1491,8 +1542,10 @@ impl PluginsManager {
config: &PluginsConfigInput,
marketplace_name: Option<&str>,
) -> Result<ConfiguredMarketplaceUpgradeOutcome, String> {
let configured_marketplace_names =
configured_git_marketplace_names(&config.config_layer_stack);
if let Some(marketplace_name) = marketplace_name
&& !configured_git_marketplace_names(&config.config_layer_stack)
&& !configured_marketplace_names
.iter()
.any(|name| name == marketplace_name)
{
@@ -1501,11 +1554,49 @@ impl PluginsManager {
));
}
let mut outcome = upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
marketplace_name,
);
let mut outcome = if config
.config_layer_stack
.requirements()
.plugin_marketplaces
.is_none()
{
upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
marketplace_name,
)
} else {
let selected_marketplace_names = match marketplace_name {
Some(marketplace_name) => {
if !marketplace_is_allowed(config, marketplace_name) {
return Err(format!(
"marketplace `{marketplace_name}` is not allowed by managed requirements"
));
}
vec![marketplace_name.to_string()]
}
None => configured_marketplace_names
.into_iter()
.filter(|marketplace_name| marketplace_is_allowed(config, marketplace_name))
.collect::<Vec<_>>(),
};
let mut outcome = ConfiguredMarketplaceUpgradeOutcome::default();
for marketplace_name in selected_marketplace_names {
let mut marketplace_outcome = upgrade_configured_git_marketplaces(
self.codex_home.as_path(),
&config.config_layer_stack,
Some(&marketplace_name),
);
outcome
.selected_marketplaces
.append(&mut marketplace_outcome.selected_marketplaces);
outcome
.upgraded_roots
.append(&mut marketplace_outcome.upgraded_roots);
outcome.errors.append(&mut marketplace_outcome.errors);
}
outcome
};
if !outcome.upgraded_roots.is_empty() {
match refresh_non_curated_plugin_cache_force_reinstall(
self.codex_home.as_path(),
@@ -1813,7 +1904,7 @@ impl PluginsManager {
&self,
config: &PluginsConfigInput,
) -> (HashSet<String>, HashSet<String>) {
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let configured_plugins = configured_plugins_for_config(config);
let installed_plugins = configured_plugins
.keys()
.filter(|plugin_key| {
@@ -1913,6 +2004,7 @@ impl PluginInstallError {
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::MarketplaceBlocked { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
@@ -1957,6 +2049,60 @@ pub(crate) fn configured_plugins_from_stack(
configured_plugins_from_user_config_value(&user_layer.config)
}
pub(crate) fn configured_plugins_for_stack(
config_layer_stack: &ConfigLayerStack,
) -> HashMap<String, PluginConfig> {
let configured_plugins = configured_plugins_from_stack(config_layer_stack);
if config_layer_stack
.requirements()
.plugin_marketplaces
.is_none()
{
return configured_plugins;
}
configured_plugins
.into_iter()
.filter(|(plugin_key, _)| plugin_key_is_allowed(config_layer_stack, plugin_key))
.collect()
}
fn marketplace_is_allowed(config: &PluginsConfigInput, marketplace_name: &str) -> bool {
marketplace_is_allowed_in_stack(&config.config_layer_stack, marketplace_name)
}
fn plugin_marketplace_requirements(
config: &PluginsConfigInput,
) -> Option<PluginMarketplaceRequirementsToml> {
config
.config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.map(|requirements| requirements.value.clone())
}
pub(crate) fn marketplace_is_allowed_in_stack(
config_layer_stack: &ConfigLayerStack,
marketplace_name: &str,
) -> bool {
config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.is_none_or(|requirements| requirements.value.allows_marketplace(marketplace_name))
}
fn plugin_key_is_allowed(config_layer_stack: &ConfigLayerStack, plugin_key: &str) -> bool {
PluginId::parse(plugin_key).ok().is_some_and(|plugin_id| {
marketplace_is_allowed_in_stack(config_layer_stack, &plugin_id.marketplace_name)
})
}
fn configured_plugins_for_config(config: &PluginsConfigInput) -> HashMap<String, PluginConfig> {
configured_plugins_for_stack(&config.config_layer_stack)
}
fn configured_plugins_from_user_config_value(
user_config: &toml::Value,
) -> HashMap<String, PluginConfig> {

View File

@@ -23,6 +23,9 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::PluginMarketplaceRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::types::McpServerTransportConfig;
use codex_login::CodexAuth;
use codex_protocol::protocol::Product;
@@ -141,6 +144,32 @@ async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> Plugi
.await
}
fn plugins_config_with_requirements(
codex_home: &Path,
user_config: Value,
requirements: ConfigRequirements,
) -> PluginsConfigInput {
let config_path =
codex_utils_absolute_path::AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE))
.expect("config path should be absolute");
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
user_config,
)],
requirements,
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
PluginsConfigInput::new(
config_layer_stack,
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
/*plugin_hooks_enabled*/ false,
"https://chatgpt.com/backend-api/".to_string(),
)
}
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
load_plugins_config_input(codex_home, cwd).await
}
@@ -1576,6 +1605,143 @@ enabled = false
);
}
#[tokio::test]
async fn managed_marketplace_allowlist_hides_and_disables_unapproved_plugins() {
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(
&tmp.path().join("plugins/cache/debug"),
"sample/local",
"sample",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./sample"
}
}
]
}"#,
)
.unwrap();
let config = plugins_config_with_requirements(
tmp.path(),
toml::toml! {
[plugins."sample@debug"]
enabled = true
}
.into(),
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["openai-curated".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let manager = PluginsManager::new(tmp.path().to_path_buf());
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(outcome.plugins(), &[]);
let marketplaces = manager
.list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()])
.unwrap()
.marketplaces;
assert_eq!(marketplaces, Vec::new());
let err = manager
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.expect_err("disallowed marketplace should not be readable");
assert!(matches!(
err,
MarketplaceError::MarketplaceBlocked { marketplace_name }
if marketplace_name == "debug"
));
let err = manager
.install_plugin_for_config(
&config,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.expect_err("disallowed marketplace should not be installable");
assert!(matches!(
err,
PluginInstallError::Marketplace(MarketplaceError::MarketplaceBlocked {
marketplace_name
}) if marketplace_name == "debug"
));
}
#[tokio::test]
async fn plugins_for_config_separates_cache_entries_by_managed_marketplace_allowlist() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample/local",
"sample",
);
let user_config: Value = toml::toml! {
[plugins."sample@debug"]
enabled = true
}
.into();
let unrestricted = plugins_config_with_requirements(
tmp.path(),
user_config.clone(),
ConfigRequirements::default(),
);
let restricted = plugins_config_with_requirements(
tmp.path(),
user_config,
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["openai-curated".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let manager = PluginsManager::new(tmp.path().to_path_buf());
let unrestricted = manager.plugins_for_config(&unrestricted).await;
assert_eq!(unrestricted.plugins().len(), 1);
let restricted = manager.plugins_for_config(&restricted).await;
assert_eq!(restricted.plugins(), &[]);
}
#[tokio::test]
async fn list_marketplaces_returns_empty_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
@@ -2827,6 +2993,65 @@ enabled = false
assert!(config.contains("enabled = false"));
}
#[tokio::test]
async fn sync_plugins_from_remote_skips_disallowed_curated_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["gmail"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let mut config = load_config(tmp.path(), tmp.path()).await;
config.config_layer_stack = ConfigLayerStack::new(
config
.config_layer_stack
.get_layers(
codex_config::ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.into_iter()
.cloned()
.collect(),
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: Some(vec!["arm-internal".to_string()]),
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
ConfigRequirementsToml::default(),
)
.unwrap();
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(result, RemotePluginSyncResult::default());
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
assert_eq!(
fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(),
"[features]\nplugins = true\n"
);
}
#[tokio::test]
async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();

View File

@@ -158,6 +158,9 @@ pub enum MarketplaceError {
#[error("plugins feature is disabled")]
PluginsDisabled,
#[error("marketplace `{marketplace_name}` is not allowed by managed requirements")]
MarketplaceBlocked { marketplace_name: String },
#[error("{0}")]
InvalidPlugin(String),
}

View File

@@ -1,5 +1,6 @@
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::installed_marketplaces::marketplace_install_root;
use codex_config::ConfigLayerStack;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs;
use std::path::Path;
@@ -56,6 +57,25 @@ pub async fn add_marketplace(
.map_err(|err| MarketplaceAddError::Internal(format!("failed to add marketplace: {err}")))?
}
pub async fn add_marketplace_for_config(
config_layer_stack: &ConfigLayerStack,
codex_home: PathBuf,
request: MarketplaceAddRequest,
) -> Result<MarketplaceAddOutcome, MarketplaceAddError> {
if !config_layer_stack
.requirements()
.plugin_marketplaces
.as_ref()
.is_none_or(|requirements| requirements.value.allows_user_additions())
{
return Err(MarketplaceAddError::InvalidRequest(
"marketplace additions are disabled by managed requirements".to_string(),
));
}
add_marketplace(codex_home, request).await
}
pub fn is_local_marketplace_source(
source: &str,
explicit_ref: Option<String>,
@@ -213,6 +233,13 @@ where
mod tests {
use super::*;
use anyhow::Result;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::PluginMarketplaceRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -253,6 +280,48 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn add_marketplace_for_config_rejects_managed_user_addition_block() -> Result<()> {
let tmp = tempfile::tempdir()?;
let config_path = AbsolutePathBuf::try_from(tmp.path().join("config.toml"))?;
let config_layer_stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
toml::Value::Table(toml::map::Map::new()),
)],
ConfigRequirements {
plugin_marketplaces: Some(Sourced::new(
PluginMarketplaceRequirementsToml {
allowed_names: None,
allow_user_additions: Some(false),
},
RequirementSource::Unknown,
)),
..Default::default()
},
ConfigRequirementsToml::default(),
)?;
let err = add_marketplace_for_config(
&config_layer_stack,
tmp.path().to_path_buf(),
MarketplaceAddRequest {
source: "owner/repo".to_string(),
ref_name: None,
sparse_paths: Vec::new(),
},
)
.await
.expect_err("managed requirements should block user marketplace additions");
assert!(matches!(
err,
MarketplaceAddError::InvalidRequest(ref message)
if message == "marketplace additions are disabled by managed requirements"
));
Ok(())
}
#[test]
fn add_marketplace_sync_installs_local_directory_source_and_updates_config() -> Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use std::sync::RwLock;
use codex_config::ConfigLayerStack;
use codex_config::SkillSourceRequirement;
use codex_exec_server::ExecutorFileSystem;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
@@ -51,7 +52,7 @@ impl SkillsLoadInput {
pub struct SkillsManager {
codex_home: AbsolutePathBuf,
restriction_product: Option<Product>,
cache_by_cwd: RwLock<HashMap<AbsolutePathBuf, SkillLoadOutcome>>,
cache_by_cwd: RwLock<HashMap<CwdSkillsCacheKey, SkillLoadOutcome>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
}
@@ -123,6 +124,7 @@ impl SkillsManager {
if !input.bundled_skills_enabled {
roots.retain(|root| root.scope != SkillScope::System);
}
retain_allowed_skill_roots(&mut roots, &input.config_layer_stack);
roots
}
@@ -144,9 +146,10 @@ impl SkillsManager {
fs: Option<Arc<dyn ExecutorFileSystem>>,
) -> SkillLoadOutcome {
let use_cwd_cache = fs.is_some();
let cache_key = cwd_skills_cache_key(&input.cwd, &input.config_layer_stack);
if use_cwd_cache
&& !force_reload
&& let Some(outcome) = self.cached_outcome_for_cwd(&input.cwd)
&& let Some(outcome) = self.cached_outcome_for_cwd(&cache_key)
{
return outcome;
}
@@ -173,6 +176,7 @@ impl SkillsManager {
}),
);
}
retain_allowed_skill_roots(&mut roots, &input.config_layer_stack);
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
let outcome = self.build_skill_outcome(roots, &skill_config_rules).await;
if use_cwd_cache {
@@ -180,7 +184,7 @@ impl SkillsManager {
.cache_by_cwd
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(input.cwd.clone(), outcome.clone());
cache.insert(cache_key, outcome.clone());
}
outcome
}
@@ -221,10 +225,10 @@ impl SkillsManager {
info!("skills cache cleared ({cleared} entries)");
}
fn cached_outcome_for_cwd(&self, cwd: &AbsolutePathBuf) -> Option<SkillLoadOutcome> {
fn cached_outcome_for_cwd(&self, cache_key: &CwdSkillsCacheKey) -> Option<SkillLoadOutcome> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
Err(err) => err.into_inner().get(cwd).cloned(),
Ok(cache) => cache.get(cache_key).cloned(),
Err(err) => err.into_inner().get(cache_key).cloned(),
}
}
@@ -239,12 +243,43 @@ impl SkillsManager {
}
}
fn retain_allowed_skill_roots(roots: &mut Vec<SkillRoot>, config_layer_stack: &ConfigLayerStack) {
let Some(requirements) = config_layer_stack.requirements().skills.as_ref() else {
return;
};
roots.retain(|root| {
requirements
.value
.allows_source(skill_source_for_root(root))
});
}
fn skill_source_for_root(root: &SkillRoot) -> SkillSourceRequirement {
if root.plugin_id.is_some() {
return SkillSourceRequirement::Plugin;
}
match root.scope {
SkillScope::Repo => SkillSourceRequirement::Repo,
SkillScope::User => SkillSourceRequirement::User,
SkillScope::System => SkillSourceRequirement::System,
SkillScope::Admin => SkillSourceRequirement::Admin,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConfigSkillsCacheKey {
roots: Vec<(AbsolutePathBuf, u8, Option<String>)>,
skill_config_rules: SkillConfigRules,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CwdSkillsCacheKey {
cwd: AbsolutePathBuf,
allowed_sources: Option<Vec<SkillSourceRequirement>>,
}
pub fn bundled_skills_enabled_from_stack(
config_layer_stack: &codex_config::ConfigLayerStack,
) -> bool {
@@ -288,6 +323,20 @@ fn config_skills_cache_key(
}
}
fn cwd_skills_cache_key(
cwd: &AbsolutePathBuf,
config_layer_stack: &ConfigLayerStack,
) -> CwdSkillsCacheKey {
CwdSkillsCacheKey {
cwd: cwd.clone(),
allowed_sources: config_layer_stack
.requirements()
.skills
.as_ref()
.and_then(|requirements| requirements.value.allowed_sources.clone()),
}
}
fn finalize_skill_outcome(
mut outcome: SkillLoadOutcome,
disabled_paths: HashSet<AbsolutePathBuf>,

View File

@@ -6,7 +6,12 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::RequirementSource;
use codex_config::SkillSourceRequirement;
use codex_config::SkillsRequirementsToml;
use codex_config::Sourced;
use codex_exec_server::LOCAL_FS;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
@@ -27,6 +32,13 @@ fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &s
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
fn write_repo_skill(cwd: &TempDir, dir: &str, name: &str, description: &str) {
let skill_dir = cwd.path().join(".agents/skills").join(dir);
fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
fn write_plugin_skill(
codex_home: &TempDir,
marketplace: &str,
@@ -94,9 +106,17 @@ fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntr
}
fn config_stack(codex_home: &TempDir, user_config_toml: &str) -> ConfigLayerStack {
config_stack_with_requirements(codex_home, user_config_toml, ConfigRequirements::default())
}
fn config_stack_with_requirements(
codex_home: &TempDir,
user_config_toml: &str,
requirements: ConfigRequirements,
) -> ConfigLayerStack {
ConfigLayerStack::new(
vec![user_config_layer(codex_home, user_config_toml)],
Default::default(),
requirements,
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack")
@@ -262,6 +282,65 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
);
}
#[tokio::test]
async fn skills_for_config_filters_disallowed_managed_sources() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
write_user_skill(&codex_home, "user", "user-skill", "from user");
write_repo_skill(&cwd, "repo", "repo-skill", "from repo");
let plugin_skill_path = write_plugin_skill(
&codex_home,
"test",
"sample",
"plugin",
"plugin-skill",
"from plugin",
);
let config_layer_stack = config_stack_with_requirements(
&codex_home,
"",
ConfigRequirements {
skills: Some(Sourced::new(
SkillsRequirementsToml {
allowed_sources: Some(vec![
SkillSourceRequirement::System,
SkillSourceRequirement::Admin,
SkillSourceRequirement::Plugin,
]),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let skills_manager = SkillsManager::new(
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let outcome = skills_for_config_with_stack(
&skills_manager,
&cwd,
&config_layer_stack,
&[plugin_skill_path
.parent()
.and_then(std::path::Path::parent)
.expect("plugin skills root")
.to_path_buf()
.abs()],
)
.await;
let skill_names = outcome
.skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<HashSet<_>>();
assert!(skill_names.contains("sample:plugin-skill"));
assert!(!skill_names.contains("user-skill"));
assert!(!skill_names.contains("repo-skill"));
}
#[tokio::test]
async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -322,6 +401,71 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors);
}
#[tokio::test]
async fn skills_for_cwd_separates_cache_entries_by_managed_allowed_sources() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
write_user_skill(&codex_home, "user", "user-skill", "from user");
let unrestricted_stack = config_stack(&codex_home, "");
let restricted_stack = config_stack_with_requirements(
&codex_home,
"",
ConfigRequirements {
skills: Some(Sourced::new(
SkillsRequirementsToml {
allowed_sources: Some(vec![SkillSourceRequirement::System]),
},
RequirementSource::Unknown,
)),
..Default::default()
},
);
let skills_manager = SkillsManager::new(
codex_home.path().abs(),
/*bundled_skills_enabled*/ true,
);
let unrestricted_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
unrestricted_stack.clone(),
bundled_skills_enabled_from_stack(&unrestricted_stack),
);
let unrestricted = skills_manager
.skills_for_cwd(
&unrestricted_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.await;
assert!(
unrestricted
.skills
.iter()
.any(|skill| skill.name == "user-skill")
);
let restricted_input = SkillsLoadInput::new(
cwd.path().abs(),
Vec::new(),
restricted_stack.clone(),
bundled_skills_enabled_from_stack(&restricted_stack),
);
let restricted = skills_manager
.skills_for_cwd(
&restricted_input,
/*force_reload*/ false,
Some(Arc::clone(&LOCAL_FS)),
)
.await;
assert!(
!restricted
.skills
.iter()
.any(|skill| skill.name == "user-skill")
);
}
#[tokio::test]
async fn skills_for_cwd_loads_repo_user_and_extra_roots_with_local_fs() {
let codex_home = tempfile::tempdir().expect("tempdir");

View File

@@ -2117,6 +2117,8 @@ impl Config {
network: network_requirements,
filesystem: filesystem_requirements,
guardian_policy_config_source: _,
skills: _,
plugin_marketplaces: _,
} = config_layer_stack.requirements().clone();
let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home))