mirror of
https://github.com/openai/codex.git
synced 2026-05-07 12:56:45 +00:00
Compare commits
6 Commits
jif/extens
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e781825b | ||
|
|
106296cb8d | ||
|
|
641f65a858 | ||
|
|
5e4c73cd78 | ||
|
|
4825e48fcd | ||
|
|
45c47d2b33 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user