diff --git a/codex-rs/config/src/cloud_requirements_composition.rs b/codex-rs/config/src/cloud_requirements_composition.rs new file mode 100644 index 0000000000..ddefc46901 --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition.rs @@ -0,0 +1,336 @@ +use self::hooks::HookDirectoryField; +use self::hooks::HookMergeState; +use self::mcp::McpMergeState; +use crate::AppsRequirementsToml; +use crate::ConfigRequirementsToml; +use crate::ConfigRequirementsWithSources; +use crate::FeatureRequirementsToml; +use crate::RequirementSource; +use crate::RequirementsExecPolicyToml; +use crate::Sourced; +use crate::config_requirements::merge_app_requirements_descending; +use std::fmt; +use thiserror::Error; + +mod hooks; +mod mcp; +mod network; +mod permissions; + +// Cloud requirements are delivered as already-prioritized TOML fragments. This +// module parses each fragment into the requirements domain object, applies any +// per-host requirements inside that fragment, and folds the parsed layers in +// bundle order. +// +// Keep the top-level field dispatch here. Domain-specific mergers live in the +// sibling modules, but this file owns the exhaustive destructuring of +// ConfigRequirementsToml so adding a new requirements field forces an explicit +// merge policy decision. + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudRequirementsFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +impl CloudRequirementsFragment { + fn source_ref(&self) -> CloudRequirementsFragmentSource { + CloudRequirementsFragmentSource { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudRequirementsFragmentSource { + pub id: String, + pub name: String, +} + +impl CloudRequirementsFragmentSource { + pub(super) fn requirement_source(&self) -> RequirementSource { + RequirementSource::EnterpriseManaged { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +impl fmt::Display for CloudRequirementsFragmentSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CloudRequirementsCompositionError { + #[error("failed to parse cloud requirements fragment {fragment}: {message}")] + Parse { + fragment: CloudRequirementsFragmentSource, + message: String, + }, + #[error( + "failed to compose cloud requirements field `{field}` between {existing_fragment} and {incoming_fragment}: {message}" + )] + Conflict { + field: String, + existing_fragment: CloudRequirementsFragmentSource, + incoming_fragment: CloudRequirementsFragmentSource, + message: String, + }, +} + +pub fn compose_cloud_requirements( + fragments: impl IntoIterator, +) -> Result, CloudRequirementsCompositionError> { + let hostname = crate::host_name(); + compose_cloud_requirements_for_hostname(fragments, hostname.as_deref()) +} + +fn compose_cloud_requirements_for_hostname( + fragments: impl IntoIterator, + hostname: Option<&str>, +) -> Result, CloudRequirementsCompositionError> { + compose_cloud_requirements_for_hostname_and_hook_directory( + fragments, + hostname, + HookDirectoryField::current_platform(), + ) +} + +fn compose_cloud_requirements_for_hostname_and_hook_directory( + fragments: impl IntoIterator, + hostname: Option<&str>, + hook_directory_field: HookDirectoryField, +) -> Result, CloudRequirementsCompositionError> { + let mut accumulator = CloudRequirementsAccumulator::new(hook_directory_field); + for fragment in fragments { + let source_ref = fragment.source_ref(); + let mut requirements: ConfigRequirementsToml = + toml::from_str(&fragment.contents).map_err(|err| { + CloudRequirementsCompositionError::Parse { + fragment: source_ref.clone(), + message: err.to_string(), + } + })?; + requirements.apply_remote_sandbox_config(hostname); + accumulator.merge_layer(source_ref, requirements)?; + } + accumulator.finish() +} + +struct CloudRequirementsAccumulator { + output: ConfigRequirementsWithSources, + hooks: HookMergeState, + mcp: McpMergeState, +} + +impl CloudRequirementsAccumulator { + fn new(hook_directory_field: HookDirectoryField) -> Self { + Self { + output: ConfigRequirementsWithSources::default(), + hooks: HookMergeState::new(hook_directory_field), + mcp: McpMergeState::default(), + } + } + + fn merge_layer( + &mut self, + source_ref: CloudRequirementsFragmentSource, + mut requirements: ConfigRequirementsToml, + ) -> Result<(), CloudRequirementsCompositionError> { + if requirements + .guardian_policy_config + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + requirements.guardian_policy_config = None; + } + + // Destructure without `..` so new requirements fields fail to compile + // until this cloud-composition path chooses the correct merge policy. + let ConfigRequirementsToml { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permissions, + remote_sandbox_config: _, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_appshots, + computer_use, + feature_requirements, + hooks, + mcp_servers, + plugins, + apps, + rules, + enforce_residency, + network, + permissions, + guardian_policy_config, + } = requirements; + + fill_first( + &mut self.output.allowed_approval_policies, + allowed_approval_policies, + &source_ref, + ); + fill_first( + &mut self.output.allowed_approvals_reviewers, + allowed_approvals_reviewers, + &source_ref, + ); + fill_first( + &mut self.output.allowed_sandbox_modes, + allowed_sandbox_modes, + &source_ref, + ); + fill_first( + &mut self.output.allowed_permissions, + allowed_permissions, + &source_ref, + ); + fill_first( + &mut self.output.allowed_web_search_modes, + allowed_web_search_modes, + &source_ref, + ); + fill_first( + &mut self.output.allow_managed_hooks_only, + allow_managed_hooks_only, + &source_ref, + ); + fill_first(&mut self.output.allow_appshots, allow_appshots, &source_ref); + fill_first(&mut self.output.computer_use, computer_use, &source_ref); + self.merge_feature_requirements(feature_requirements, &source_ref); + self.hooks + .merge(&mut self.output.hooks, hooks, &source_ref)?; + self.mcp + .merge_mcp_servers(&mut self.output.mcp_servers, mcp_servers, &source_ref)?; + self.mcp + .merge_plugins(&mut self.output.plugins, plugins, &source_ref)?; + self.merge_apps(apps, &source_ref); + self.merge_rules(rules, &source_ref); + fill_first( + &mut self.output.enforce_residency, + enforce_residency, + &source_ref, + ); + network::merge_network(&mut self.output.network, network, &source_ref); + permissions::merge_permissions(&mut self.output.permissions, permissions, &source_ref); + fill_first( + &mut self.output.guardian_policy_config, + guardian_policy_config, + &source_ref, + ); + + Ok(()) + } + + fn merge_feature_requirements( + &mut self, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, + ) { + let Some(incoming) = incoming.filter(|value| !value.is_empty()) else { + return; + }; + let Some(existing) = self.output.feature_requirements.as_mut() else { + self.output.feature_requirements = + Some(Sourced::new(incoming, source_ref.requirement_source())); + return; + }; + + for (feature, enabled) in incoming.entries { + if let std::collections::btree_map::Entry::Vacant(entry) = + existing.value.entries.entry(feature) + { + entry.insert(enabled); + merge_output_source(&mut existing.source, source_ref); + } + } + } + + fn merge_apps( + &mut self, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, + ) { + let Some(incoming) = incoming.filter(|apps| !apps.is_empty()) else { + return; + }; + let Some(existing) = self.output.apps.as_mut() else { + self.output.apps = Some(Sourced::new(incoming, source_ref.requirement_source())); + return; + }; + + merge_app_requirements_descending(&mut existing.value, incoming); + merge_output_source(&mut existing.source, source_ref); + } + + fn merge_rules( + &mut self, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, + ) { + let Some(incoming) = incoming else { + return; + }; + let Some(existing) = self.output.rules.as_mut() else { + self.output.rules = Some(Sourced::new(incoming, source_ref.requirement_source())); + return; + }; + + existing.value.prefix_rules.extend(incoming.prefix_rules); + merge_output_source(&mut existing.source, source_ref); + } + + fn finish( + self, + ) -> Result, CloudRequirementsCompositionError> { + let output_is_empty = self.output.clone().into_toml().is_empty(); + Ok((!output_is_empty).then_some(self.output)) + } +} + +fn fill_first( + target: &mut Option>, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, +) { + if target.is_none() + && let Some(value) = incoming + { + *target = Some(Sourced::new(value, source_ref.requirement_source())); + } +} + +pub(super) fn merge_output_source( + existing: &mut RequirementSource, + incoming: &CloudRequirementsFragmentSource, +) { + let incoming = incoming.requirement_source(); + if *existing != incoming { + *existing = RequirementSource::CloudRequirements; + } +} + +pub(super) fn composition_conflict( + field: String, + existing_fragment: CloudRequirementsFragmentSource, + incoming_fragment: CloudRequirementsFragmentSource, + message: impl Into, +) -> CloudRequirementsCompositionError { + CloudRequirementsCompositionError::Conflict { + field, + existing_fragment, + incoming_fragment, + message: message.into(), + } +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/config/src/cloud_requirements_composition/hooks.rs b/codex-rs/config/src/cloud_requirements_composition/hooks.rs new file mode 100644 index 0000000000..7ac0363eca --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition/hooks.rs @@ -0,0 +1,235 @@ +use super::CloudRequirementsCompositionError; +use super::CloudRequirementsFragmentSource; +use super::composition_conflict; +use super::merge_output_source; +use crate::HookEventsToml; +use crate::ManagedHooksRequirementsToml; +use crate::Sourced; +use std::collections::BTreeMap; +use std::path::PathBuf; + +// Hook events are append-only across cloud layers. The managed hook directory is +// different: only one directory is usable on a given platform, so conflicting +// values for the active platform fail closed. The inactive platform field is +// first-filled to allow the same bundle to carry OS-specific directories. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum HookDirectoryField { + #[default] + ManagedDir, + WindowsManagedDir, +} + +impl HookDirectoryField { + pub(super) fn current_platform() -> Self { + if cfg!(windows) { + Self::WindowsManagedDir + } else { + Self::ManagedDir + } + } + + fn field_name(self) -> &'static str { + match self { + Self::ManagedDir => "hooks.managed_dir", + Self::WindowsManagedDir => "hooks.windows_managed_dir", + } + } + + fn inactive(self) -> Self { + match self { + Self::ManagedDir => Self::WindowsManagedDir, + Self::WindowsManagedDir => Self::ManagedDir, + } + } +} + +pub(super) struct HookMergeState { + directory_field: HookDirectoryField, + dir_sources: BTreeMap, +} + +impl HookMergeState { + pub(super) fn new(directory_field: HookDirectoryField) -> Self { + Self { + directory_field, + dir_sources: BTreeMap::new(), + } + } + + pub(super) fn merge( + &mut self, + target: &mut Option>, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, + ) -> Result<(), CloudRequirementsCompositionError> { + let Some(mut incoming) = incoming.filter(|value| !value.is_empty()) else { + return Ok(()); + }; + let Some(existing) = target.as_mut() else { + self.track_singleton_source( + HookDirectoryField::ManagedDir, + &incoming.managed_dir, + source_ref, + ); + self.track_singleton_source( + HookDirectoryField::WindowsManagedDir, + &incoming.windows_managed_dir, + source_ref, + ); + *target = Some(Sourced::new(incoming, source_ref.requirement_source())); + return Ok(()); + }; + + let active_field = self.directory_field; + let inactive_field = active_field.inactive(); + let incoming_active_dir = take_hook_dir(&mut incoming, active_field); + let incoming_inactive_dir = take_hook_dir(&mut incoming, inactive_field); + let mut changed = false; + changed |= self.merge_active_singleton( + active_field, + hook_dir_mut(&mut existing.value, active_field), + incoming_active_dir, + source_ref, + )?; + changed |= self.fill_singleton( + inactive_field, + hook_dir_mut(&mut existing.value, inactive_field), + incoming_inactive_dir, + source_ref, + ); + changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks); + if changed { + merge_output_source(&mut existing.source, source_ref); + } + Ok(()) + } + + fn track_singleton_source( + &mut self, + field: HookDirectoryField, + value: &Option, + source_ref: &CloudRequirementsFragmentSource, + ) { + if value.is_some() { + self.dir_sources + .entry(field) + .or_insert_with(|| source_ref.clone()); + } + } + + fn merge_active_singleton( + &mut self, + field: HookDirectoryField, + existing: &mut Option, + incoming: Option, + incoming_source: &CloudRequirementsFragmentSource, + ) -> Result { + let Some(incoming) = incoming else { + return Ok(false); + }; + + match existing { + Some(existing_value) if existing_value != &incoming => { + let existing_source = self + .dir_sources + .get(&field) + .cloned() + .unwrap_or_else(|| incoming_source.clone()); + Err(composition_conflict( + field.field_name().to_string(), + existing_source, + incoming_source.clone(), + format!( + "`{}` conflicts with `{}`", + existing_value.display(), + incoming.display() + ), + )) + } + Some(_) => Ok(false), + None => { + *existing = Some(incoming); + self.dir_sources + .entry(field) + .or_insert_with(|| incoming_source.clone()); + Ok(true) + } + } + } + + fn fill_singleton( + &mut self, + field: HookDirectoryField, + existing: &mut Option, + incoming: Option, + incoming_source: &CloudRequirementsFragmentSource, + ) -> bool { + if existing.is_none() + && let Some(incoming) = incoming + { + *existing = Some(incoming); + self.dir_sources + .entry(field) + .or_insert_with(|| incoming_source.clone()); + return true; + } + false + } +} + +fn take_hook_dir( + hooks: &mut ManagedHooksRequirementsToml, + field: HookDirectoryField, +) -> Option { + match field { + HookDirectoryField::ManagedDir => hooks.managed_dir.take(), + HookDirectoryField::WindowsManagedDir => hooks.windows_managed_dir.take(), + } +} + +fn hook_dir_mut( + hooks: &mut ManagedHooksRequirementsToml, + field: HookDirectoryField, +) -> &mut Option { + match field { + HookDirectoryField::ManagedDir => &mut hooks.managed_dir, + HookDirectoryField::WindowsManagedDir => &mut hooks.windows_managed_dir, + } +} + +fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -> bool { + // Destructure without `..` so new hook events cannot be introduced without + // deciding whether cloud composition should append them. + let HookEventsToml { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + subagent_start, + subagent_stop, + stop, + } = incoming; + + let mut changed = false; + changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use); + changed |= append_vec(&mut existing.permission_request, permission_request); + changed |= append_vec(&mut existing.post_tool_use, post_tool_use); + changed |= append_vec(&mut existing.pre_compact, pre_compact); + changed |= append_vec(&mut existing.post_compact, post_compact); + changed |= append_vec(&mut existing.session_start, session_start); + changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit); + changed |= append_vec(&mut existing.subagent_start, subagent_start); + changed |= append_vec(&mut existing.subagent_stop, subagent_stop); + changed |= append_vec(&mut existing.stop, stop); + changed +} + +fn append_vec(existing: &mut Vec, mut incoming: Vec) -> bool { + let changed = !incoming.is_empty(); + existing.append(&mut incoming); + changed +} diff --git a/codex-rs/config/src/cloud_requirements_composition/mcp.rs b/codex-rs/config/src/cloud_requirements_composition/mcp.rs new file mode 100644 index 0000000000..d409b7e850 --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition/mcp.rs @@ -0,0 +1,164 @@ +use super::CloudRequirementsCompositionError; +use super::CloudRequirementsFragmentSource; +use super::composition_conflict; +use super::merge_output_source; +use crate::McpServerRequirement; +use crate::PluginRequirementsToml; +use crate::Sourced; +use std::collections::BTreeMap; + +// MCP requirements merge as keyed unions. Repeating an identical server +// definition is allowed, but conflicting definitions for the same key fail +// closed because transport/identity fields do not have independent semantics. +// Plugin-scoped MCP servers follow the same rule within each plugin id. + +#[derive(Default)] +pub(super) struct McpMergeState { + mcp_server_sources: BTreeMap, + plugin_mcp_server_sources: BTreeMap<(String, String), CloudRequirementsFragmentSource>, +} + +impl McpMergeState { + pub(super) fn merge_mcp_servers( + &mut self, + target: &mut Option>>, + incoming: Option>, + source_ref: &CloudRequirementsFragmentSource, + ) -> Result<(), CloudRequirementsCompositionError> { + let Some(incoming) = incoming else { + return Ok(()); + }; + let Some(existing) = target.as_mut() else { + self.mcp_server_sources.extend( + incoming + .keys() + .map(|server_id| (server_id.clone(), source_ref.clone())), + ); + *target = Some(Sourced::new(incoming, source_ref.requirement_source())); + return Ok(()); + }; + + for (server_id, server_requirement) in incoming { + match existing.value.get(&server_id) { + Some(existing_requirement) if existing_requirement != &server_requirement => { + let existing_source = self + .mcp_server_sources + .get(&server_id) + .cloned() + .unwrap_or_else(|| source_ref.clone()); + return Err(composition_conflict( + format!("mcp_servers.{server_id}"), + existing_source, + source_ref.clone(), + "server definitions differ", + )); + } + Some(_) => {} + None => { + existing.value.insert(server_id.clone(), server_requirement); + self.mcp_server_sources + .insert(server_id, source_ref.clone()); + merge_output_source(&mut existing.source, source_ref); + } + } + } + Ok(()) + } + + pub(super) fn merge_plugins( + &mut self, + target: &mut Option>>, + incoming: Option>, + source_ref: &CloudRequirementsFragmentSource, + ) -> Result<(), CloudRequirementsCompositionError> { + let Some(incoming) = + incoming.filter(|plugins| !plugins.values().all(PluginRequirementsToml::is_empty)) + else { + return Ok(()); + }; + let Some(existing) = target.as_mut() else { + track_plugin_mcp_sources(&mut self.plugin_mcp_server_sources, &incoming, source_ref); + *target = Some(Sourced::new(incoming, source_ref.requirement_source())); + return Ok(()); + }; + + for (plugin_id, plugin_requirement) in incoming { + let existing_plugin = existing.value.entry(plugin_id.clone()).or_default(); + if merge_plugin_requirement( + &plugin_id, + existing_plugin, + plugin_requirement, + &mut self.plugin_mcp_server_sources, + source_ref, + )? { + merge_output_source(&mut existing.source, source_ref); + } + } + Ok(()) + } +} + +fn track_plugin_mcp_sources( + target: &mut BTreeMap<(String, String), CloudRequirementsFragmentSource>, + plugins: &BTreeMap, + source_ref: &CloudRequirementsFragmentSource, +) { + for (plugin_id, plugin) in plugins { + let Some(mcp_servers) = plugin.mcp_servers.as_ref() else { + continue; + }; + for server_id in mcp_servers.keys() { + target.insert((plugin_id.clone(), server_id.clone()), source_ref.clone()); + } + } +} + +fn merge_plugin_requirement( + plugin_id: &str, + existing: &mut PluginRequirementsToml, + incoming: PluginRequirementsToml, + sources: &mut BTreeMap<(String, String), CloudRequirementsFragmentSource>, + source_ref: &CloudRequirementsFragmentSource, +) -> Result { + // Destructure without `..` so new plugin requirement fields cannot silently + // skip cloud composition. + let PluginRequirementsToml { mcp_servers } = incoming; + let Some(incoming_servers) = mcp_servers else { + return Ok(false); + }; + let Some(existing_servers) = existing.mcp_servers.as_mut() else { + for server_id in incoming_servers.keys() { + sources.insert( + (plugin_id.to_string(), server_id.clone()), + source_ref.clone(), + ); + } + existing.mcp_servers = Some(incoming_servers); + return Ok(true); + }; + + let mut changed = false; + for (server_id, server_requirement) in incoming_servers { + match existing_servers.get(&server_id) { + Some(existing_requirement) if existing_requirement != &server_requirement => { + let existing_source = sources + .get(&(plugin_id.to_string(), server_id.clone())) + .cloned() + .unwrap_or_else(|| source_ref.clone()); + return Err(composition_conflict( + format!("plugins.{plugin_id}.mcp_servers.{server_id}"), + existing_source, + source_ref.clone(), + "server definitions differ", + )); + } + Some(_) => {} + None => { + existing_servers.insert(server_id.clone(), server_requirement); + sources.insert((plugin_id.to_string(), server_id), source_ref.clone()); + changed = true; + } + } + } + Ok(changed) +} diff --git a/codex-rs/config/src/cloud_requirements_composition/network.rs b/codex-rs/config/src/cloud_requirements_composition/network.rs new file mode 100644 index 0000000000..eeec113974 --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition/network.rs @@ -0,0 +1,139 @@ +use super::CloudRequirementsFragmentSource; +use super::merge_output_source; +use crate::NetworkDomainPermissionsToml; +use crate::NetworkRequirementsToml; +use crate::NetworkUnixSocketPermissionsToml; +use crate::Sourced; + +// Network scalar fields are first-wins in bundle order: once a higher-priority +// layer sets a scalar, lower-priority layers cannot change it. +// +// Domain permissions and Unix socket permissions are the notable exception: the +// final value is a union of entries from every cloud layer, not the map from a +// single highest-priority layer. This lets admins split allow/deny entries +// across cloud layers. +// +// When multiple layers define the same key, the highest-priority layer wins for +// that key. Lower-priority layers can add new entries, but they cannot change an +// existing entry from a higher-priority layer. + +pub(super) fn merge_network( + target: &mut Option>, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, +) { + let Some(incoming) = incoming.filter(|network| network != &NetworkRequirementsToml::default()) + else { + return; + }; + let Some(existing) = target.as_mut() else { + *target = Some(Sourced::new(incoming, source_ref.requirement_source())); + return; + }; + + if merge_network_requirements(&mut existing.value, incoming) { + merge_output_source(&mut existing.source, source_ref); + } +} + +fn merge_network_requirements( + existing: &mut NetworkRequirementsToml, + incoming: NetworkRequirementsToml, +) -> bool { + // Destructure without `..` so every new network field gets an explicit + // cloud-composition rule. + let NetworkRequirementsToml { + enabled, + http_port, + socks_port, + allow_upstream_proxy, + dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets, + domains, + managed_allowed_domains_only, + unix_sockets, + allow_local_binding, + } = incoming; + + let mut changed = false; + changed |= fill_optional(&mut existing.enabled, enabled); + changed |= fill_optional(&mut existing.http_port, http_port); + changed |= fill_optional(&mut existing.socks_port, socks_port); + changed |= fill_optional(&mut existing.allow_upstream_proxy, allow_upstream_proxy); + changed |= fill_optional( + &mut existing.dangerously_allow_non_loopback_proxy, + dangerously_allow_non_loopback_proxy, + ); + changed |= fill_optional( + &mut existing.dangerously_allow_all_unix_sockets, + dangerously_allow_all_unix_sockets, + ); + changed |= merge_domain_permissions(&mut existing.domains, domains); + changed |= fill_optional( + &mut existing.managed_allowed_domains_only, + managed_allowed_domains_only, + ); + changed |= merge_unix_socket_permissions(&mut existing.unix_sockets, unix_sockets); + changed |= fill_optional(&mut existing.allow_local_binding, allow_local_binding); + changed +} + +fn fill_optional(target: &mut Option, incoming: Option) -> bool { + if target.is_none() + && let Some(value) = incoming + { + *target = Some(value); + return true; + } + false +} + +fn merge_domain_permissions( + existing: &mut Option, + incoming: Option, +) -> bool { + let Some(incoming) = incoming.filter(|permissions| !permissions.is_empty()) else { + return false; + }; + let Some(existing) = existing.as_mut() else { + *existing = Some(incoming); + return true; + }; + + // Insert all domain entries from every layer into one final map. New domain + // patterns are appended; duplicate patterns keep the value from the + // highest-priority layer. + let mut changed = false; + for (domain, permission) in incoming.entries { + if let std::collections::btree_map::Entry::Vacant(entry) = existing.entries.entry(domain) { + entry.insert(permission); + changed = true; + } + } + changed +} + +fn merge_unix_socket_permissions( + existing: &mut Option, + incoming: Option, +) -> bool { + let Some(incoming) = incoming.filter(|permissions| !permissions.is_empty()) else { + return false; + }; + let Some(existing) = existing.as_mut() else { + *existing = Some(incoming); + return true; + }; + + // Insert all Unix socket entries from every layer into one final map. New + // socket paths are appended; duplicate paths keep the value from the + // highest-priority layer. + let mut changed = false; + for (path, permission) in incoming.entries { + if let std::collections::btree_map::Entry::Vacant(entry) = existing.entries.entry(path) { + entry.insert(permission); + changed = true; + } + } + changed +} diff --git a/codex-rs/config/src/cloud_requirements_composition/permissions.rs b/codex-rs/config/src/cloud_requirements_composition/permissions.rs new file mode 100644 index 0000000000..52aca86890 --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition/permissions.rs @@ -0,0 +1,80 @@ +use super::CloudRequirementsFragmentSource; +use super::merge_output_source; +use crate::Sourced; +use crate::config_requirements::FilesystemRequirementsToml; +use crate::config_requirements::PermissionsRequirementsToml; + +// Permissions compose filesystem deny_read requirements and permission profile +// definitions. The deny_read list is a stable union in bundle order, with +// duplicates removed. Profile definitions merge by key, with the first +// definition winning because cloud fragments are already priority ordered. + +pub(super) fn merge_permissions( + target: &mut Option>, + incoming: Option, + source_ref: &CloudRequirementsFragmentSource, +) { + let Some(incoming) = incoming.filter(permissions_has_mergeable_content) else { + return; + }; + let Some(existing) = target.as_mut() else { + *target = Some(Sourced::new(incoming, source_ref.requirement_source())); + return; + }; + + if merge_permissions_requirements(&mut existing.value, incoming) { + merge_output_source(&mut existing.source, source_ref); + } +} + +fn permissions_has_mergeable_content(permissions: &PermissionsRequirementsToml) -> bool { + if !permissions.profiles.is_empty() { + return true; + } + + permissions + .filesystem + .as_ref() + .and_then(|filesystem| filesystem.deny_read.as_ref()) + .is_some_and(|deny_read| !deny_read.is_empty()) +} + +fn merge_permissions_requirements( + existing: &mut PermissionsRequirementsToml, + incoming: PermissionsRequirementsToml, +) -> bool { + // Destructure without `..` so new permission families cannot bypass cloud + // composition without an explicit merge policy. + let PermissionsRequirementsToml { + filesystem, + profiles, + } = incoming; + let mut changed = false; + for (profile_name, profile) in profiles { + if let std::collections::btree_map::Entry::Vacant(entry) = + existing.profiles.entry(profile_name) + { + entry.insert(profile); + changed = true; + } + } + + // Destructure without `..` so new filesystem permission fields must choose + // their own merge behavior. + let Some(FilesystemRequirementsToml { deny_read }) = filesystem else { + return changed; + }; + let Some(incoming_deny_read) = deny_read.filter(|patterns| !patterns.is_empty()) else { + return changed; + }; + + let existing_filesystem = existing.filesystem.get_or_insert_with(Default::default); + let existing_deny_read = existing_filesystem.deny_read.get_or_insert_with(Vec::new); + for pattern in incoming_deny_read { + if !existing_deny_read.contains(&pattern) { + existing_deny_read.push(pattern); + changed = true; + } + } + changed +} diff --git a/codex-rs/config/src/cloud_requirements_composition/tests.rs b/codex-rs/config/src/cloud_requirements_composition/tests.rs new file mode 100644 index 0000000000..b792d9c8d6 --- /dev/null +++ b/codex-rs/config/src/cloud_requirements_composition/tests.rs @@ -0,0 +1,910 @@ +use super::*; +use crate::AppRequirementToml; +use crate::AppToolApproval; +use crate::AppToolRequirementToml; +use crate::AppToolsRequirementsToml; +use crate::AppsRequirementsToml; +use crate::FeatureRequirementsToml; +use crate::HookEventsToml; +use crate::HookHandlerConfig; +use crate::ManagedHooksRequirementsToml; +use crate::MatcherGroup; +use crate::NetworkDomainPermissionToml; +use crate::NetworkDomainPermissionsToml; +use crate::NetworkUnixSocketPermissionToml; +use crate::NetworkUnixSocketPermissionsToml; +use crate::RequirementSource; +use crate::RequirementsExecPolicyDecisionToml; +use crate::RequirementsExecPolicyPatternTokenToml; +use crate::RequirementsExecPolicyPrefixRuleToml; +use crate::SandboxModeRequirement; +use crate::Sourced; +use crate::config_requirements::FilesystemRequirementsToml; +use crate::config_requirements::PermissionsRequirementsToml; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::PathBuf; + +// These tests intentionally exercise the composition boundary instead of the +// private helper modules. The public behavior depends on parsing, layer order, +// source provenance, and diagnostics together. Add focused cases here when a +// merge policy changes; use helper-level tests only for purely local algorithms. + +fn fragment(id: &str, name: &str, contents: &str) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: id.to_string(), + name: name.to_string(), + contents: contents.to_string(), + } +} + +fn compose( + fragments: Vec, +) -> Result, CloudRequirementsCompositionError> { + Ok( + compose_cloud_requirements_for_hostname(fragments, /*hostname*/ None)? + .map(ConfigRequirementsWithSources::into_toml), + ) +} + +fn compose_with_hook_directory_field( + fragments: Vec, + hook_directory_field: HookDirectoryField, +) -> Result, CloudRequirementsCompositionError> { + Ok(compose_cloud_requirements_for_hostname_and_hook_directory( + fragments, + /*hostname*/ None, + hook_directory_field, + )? + .map(ConfigRequirementsWithSources::into_toml)) +} + +#[test] +fn empty_fragments_compose_to_none() { + let composed = compose(Vec::new()).expect("compose empty fragments"); + assert_eq!(composed, None); +} + +#[test] +fn first_wins_for_top_level_fields() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +allowed_approval_policies = ["never"] +allowed_sandbox_modes = ["read-only"] +"#, + ), + fragment( + "req_low", + "Low", + r#" +allowed_approval_policies = ["on-request"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.allowed_approval_policies, + Some(vec![AskForApproval::Never]) + ); + assert_eq!( + composed.allowed_sandbox_modes, + Some(vec![SandboxModeRequirement::ReadOnly]) + ); +} + +#[test] +fn scalar_fields_keep_enterprise_managed_source() { + let composed = compose_cloud_requirements_for_hostname( + vec![fragment( + "req_1", + "Security baseline", + r#" +allow_managed_hooks_only = true +"#, + )], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.allow_managed_hooks_only, + Some(Sourced::new( + /*value*/ true, + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Security baseline".to_string(), + }, + )) + ); +} + +#[test] +fn remote_sandbox_config_is_applied_per_fragment() { + let composed = compose_cloud_requirements_for_hostname( + vec![ + fragment( + "req_high", + "High", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + fragment( + "req_low", + "Low", + r#" +allowed_sandbox_modes = ["read-only"] +"#, + ), + ], + Some("BUILD-01.EXAMPLE.COM."), + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!( + composed.allowed_sandbox_modes, + Some(vec![SandboxModeRequirement::WorkspaceWrite]) + ); +} + +#[test] +fn feature_requirements_are_key_first_wins() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[features] +alpha = true +shared = true +"#, + ), + fragment( + "req_low", + "Low", + r#" +[features] +beta = false +shared = false +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.feature_requirements, + Some(FeatureRequirementsToml { + entries: BTreeMap::from([ + ("alpha".to_string(), true), + ("beta".to_string(), false), + ("shared".to_string(), true), + ]), + }) + ); +} + +#[test] +fn mcp_servers_union_compatible_duplicates() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[mcp_servers.shared.identity] +command = "shared-mcp" + +[mcp_servers.high.identity] +url = "https://high.example.com/mcp" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[mcp_servers.shared.identity] +command = "shared-mcp" + +[mcp_servers.low.identity] +command = "low-mcp" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + let mcp_servers = composed.mcp_servers.expect("mcp servers"); + assert_eq!(mcp_servers.len(), 3); +} + +#[test] +fn empty_mcp_servers_allowlist_is_preserved() { + let composed = compose(vec![fragment( + "req_high", + "High", + r#" +[mcp_servers] +"#, + )]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!(composed.mcp_servers, Some(BTreeMap::new())); +} + +#[test] +fn mcp_servers_conflict_on_incompatible_duplicates() { + let err = compose(vec![ + fragment( + "req_high", + "High", + r#" +[mcp_servers.shared.identity] +command = "high-mcp" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[mcp_servers.shared.identity] +command = "low-mcp" +"#, + ), + ]) + .expect_err("incompatible mcp servers should fail closed"); + + assert!(err.to_string().contains("mcp_servers.shared")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn plugin_mcp_servers_conflict_on_incompatible_duplicates() { + let err = compose(vec![ + fragment( + "req_high", + "High", + r#" +[plugins.search.mcp_servers.shared.identity] +command = "high-mcp" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[plugins.search.mcp_servers.shared.identity] +command = "low-mcp" +"#, + ), + ]) + .expect_err("incompatible plugin mcp servers should fail closed"); + + assert!( + err.to_string() + .contains("plugins.search.mcp_servers.shared") + ); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn empty_plugin_mcp_servers_allowlist_is_preserved() { + let composed = compose(vec![fragment( + "req_high", + "High", + r#" +[plugins.search.mcp_servers] +"#, + )]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.plugins, + Some(BTreeMap::from([( + "search".to_string(), + crate::PluginRequirementsToml { + mcp_servers: Some(BTreeMap::new()), + } + )])) + ); +} + +#[test] +fn network_maps_union_unique_keys_and_keep_highest_priority_duplicates() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[experimental_network.domains] +"example.com" = "allow" +"high.example.com" = "allow" +"internal.example.com" = "deny" + +[experimental_network.unix_sockets] +"/tmp/shared.sock" = "allow" +"/tmp/high.sock" = "allow" +"/tmp/admin.sock" = "none" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[experimental_network.domains] +"example.com" = "deny" +"low.example.com" = "deny" +"internal.example.com" = "allow" + +[experimental_network.unix_sockets] +"/tmp/shared.sock" = "none" +"/tmp/low.sock" = "allow" +"/tmp/admin.sock" = "allow" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + let network = composed.network.expect("network requirements"); + assert_eq!( + network.domains, + Some(NetworkDomainPermissionsToml { + entries: BTreeMap::from([ + ( + "example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "high.example.com".to_string(), + NetworkDomainPermissionToml::Allow, + ), + ( + "internal.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ( + "low.example.com".to_string(), + NetworkDomainPermissionToml::Deny, + ), + ]), + }) + ); + assert_eq!( + network.unix_sockets, + Some(NetworkUnixSocketPermissionsToml { + entries: BTreeMap::from([ + ( + "/tmp/admin.sock".to_string(), + NetworkUnixSocketPermissionToml::None, + ), + ( + "/tmp/high.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/low.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ( + "/tmp/shared.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), + }) + ); +} + +#[test] +fn filesystem_deny_read_is_union_deduped() { + let high_path = if cfg!(windows) { + "C:\\secret" + } else { + "/secret" + }; + let low_path = if cfg!(windows) { + "C:\\other-secret" + } else { + "/other-secret" + }; + let composed = compose(vec![ + fragment( + "req_high", + "High", + &format!( + r#" +[permissions.filesystem] +deny_read = [{high_path:?}] +"# + ), + ), + fragment( + "req_low", + "Low", + &format!( + r#" +[permissions.filesystem] +deny_read = [{high_path:?}, {low_path:?}] +"# + ), + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + let permissions = composed.permissions.expect("permissions"); + assert_eq!( + permissions, + PermissionsRequirementsToml { + filesystem: Some(FilesystemRequirementsToml { + deny_read: Some(vec![ + AbsolutePathBuf::from_absolute_path(high_path) + .expect("absolute path") + .into(), + AbsolutePathBuf::from_absolute_path(low_path) + .expect("absolute path") + .into(), + ]), + }), + profiles: BTreeMap::new(), + } + ); +} + +#[test] +fn permission_profiles_merge_by_name_with_highest_priority_winning() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[permissions.managed-standard] +description = "High profile" +extends = ":read-only" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[permissions.managed-standard] +description = "Low profile" +extends = ":workspace" + +[permissions.managed-build] +extends = ":workspace" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + let permissions = composed.permissions.expect("permissions"); + assert_eq!( + permissions.profiles.keys().collect::>(), + vec!["managed-build", "managed-standard"] + ); + assert_eq!( + permissions + .profiles + .get("managed-standard") + .and_then(|profile| profile.description.as_deref()), + Some("High profile") + ); +} + +#[test] +fn rules_are_appended_in_bundle_order() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[[rules.prefix_rules]] +pattern = [{ token = "git" }] +decision = "forbidden" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[[rules.prefix_rules]] +pattern = [{ token = "npm" }] +decision = "prompt" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + let rules = composed.rules.expect("rules"); + assert_eq!( + rules, + RequirementsExecPolicyToml { + prefix_rules: vec![ + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("git".to_string()), + any_of: None, + }], + decision: Some(RequirementsExecPolicyDecisionToml::Forbidden), + justification: None, + }, + RequirementsExecPolicyPrefixRuleToml { + pattern: vec![RequirementsExecPolicyPatternTokenToml { + token: Some("npm".to_string()), + any_of: None, + }], + decision: Some(RequirementsExecPolicyDecisionToml::Prompt), + justification: None, + }, + ], + } + ); +} + +#[test] +fn hooks_append_groups_and_reject_conflicting_managed_dirs() { + let composed = compose_with_hook_directory_field( + vec![ + fragment( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/hooks" + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/hooks" + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect("compose requirements") + .expect("requirements present"); + + let hooks = composed.hooks.expect("hooks"); + assert_eq!( + hooks, + ManagedHooksRequirementsToml { + managed_dir: Some(PathBuf::from("/managed/hooks")), + windows_managed_dir: None, + hooks: HookEventsToml { + pre_tool_use: vec![ + MatcherGroup { + matcher: Some("Edit".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "high".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }], + }, + MatcherGroup { + matcher: Some("Bash".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "low".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }], + }, + ], + ..HookEventsToml::default() + }, + } + ); + + let err = compose_with_hook_directory_field( + vec![ + fragment( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/high" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/low" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect_err("conflicting managed dirs should fail closed"); + assert!(err.to_string().contains("hooks.managed_dir")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn active_windows_managed_dir_conflicts_fail_closed() { + let err = compose_with_hook_directory_field( + vec![ + fragment( + "req_high", + "High", + r#" +[hooks] +windows_managed_dir = 'C:\managed\high' +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +windows_managed_dir = 'C:\managed\low' +"#, + ), + ], + HookDirectoryField::WindowsManagedDir, + ) + .expect_err("conflicting windows managed dirs should fail closed"); + + assert!(err.to_string().contains("hooks.windows_managed_dir")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn inactive_hook_dir_conflicts_do_not_fail_composition() { + let composed = compose_with_hook_directory_field( + vec![ + fragment( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/hooks" +windows_managed_dir = 'C:\managed\high' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/hooks" +windows_managed_dir = 'C:\managed\low' + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect("inactive windows managed dir conflict should not fail") + .expect("requirements present"); + + let hooks = composed.hooks.expect("hooks"); + assert_eq!(hooks.managed_dir, Some(PathBuf::from("/managed/hooks"))); + assert_eq!( + hooks.windows_managed_dir, + Some(PathBuf::from(r"C:\managed\high")) + ); + assert_eq!(hooks.hooks.pre_tool_use.len(), 2); + + let composed = compose_with_hook_directory_field( + vec![ + fragment( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/high" +windows_managed_dir = 'C:\managed\hooks' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/low" +windows_managed_dir = 'C:\managed\hooks' + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + ], + HookDirectoryField::WindowsManagedDir, + ) + .expect("inactive managed dir conflict should not fail") + .expect("requirements present"); + + let hooks = composed.hooks.expect("hooks"); + assert_eq!(hooks.managed_dir, Some(PathBuf::from("/managed/high"))); + assert_eq!( + hooks.windows_managed_dir, + Some(PathBuf::from(r"C:\managed\hooks")) + ); + assert_eq!(hooks.hooks.pre_tool_use.len(), 2); +} + +#[test] +fn hook_source_collapses_when_later_layer_sets_managed_dir() { + let composed = compose_cloud_requirements_for_hostname_and_hook_directory( + vec![ + fragment( + "req_high", + "High", + r#" +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/hooks" +"#, + ), + ], + /*hostname*/ None, + HookDirectoryField::ManagedDir, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.hooks, + Some(Sourced::new( + ManagedHooksRequirementsToml { + managed_dir: Some(PathBuf::from("/managed/hooks")), + windows_managed_dir: None, + hooks: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("Edit".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "high".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..HookEventsToml::default() + }, + }, + RequirementSource::CloudRequirements, + )) + ); +} + +#[test] +fn apps_reuse_disable_wins_behavior() { + let composed = compose(vec![ + fragment( + "req_high", + "High", + r#" +[apps.connector_1] +enabled = true + +[apps.connector_1.tools.search] +approval_mode = "approve" +"#, + ), + fragment( + "req_low", + "Low", + r#" +[apps.connector_1] +enabled = false + +[apps.connector_1.tools.search] +approval_mode = "prompt" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_1".to_string(), + AppRequirementToml { + enabled: Some(false), + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + "search".to_string(), + AppToolRequirementToml { + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), + }, + )]), + }) + ); +} + +#[test] +fn parse_error_names_fragment() { + let err = compose(vec![fragment( + "req_bad", + "Bad layer", + "allowed_approval_policies = [1]", + )]) + .expect_err("invalid fragment should fail"); + + assert!(err.to_string().contains("Bad layer (req_bad)")); + assert!(err.to_string().contains("allowed_approval_policies")); +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 93d16d3ccf..cb87a189ad 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -26,6 +26,7 @@ pub enum RequirementSource { Unknown, MdmManagedPreferences { domain: String, key: String }, CloudRequirements, + EnterpriseManaged { id: String, name: String }, SystemRequirementsToml { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf }, LegacyManagedConfigTomlFromMdm, @@ -41,6 +42,9 @@ impl fmt::Display for RequirementSource { RequirementSource::CloudRequirements => { write!(f, "cloud requirements") } + RequirementSource::EnterpriseManaged { id, name } => { + write!(f, "enterprise-managed requirements {name} ({id})") + } RequirementSource::SystemRequirementsToml { file } => { write!(f, "{}", file.as_path().display()) } @@ -168,7 +172,7 @@ pub struct PluginRequirementsToml { impl PluginRequirementsToml { pub fn is_empty(&self) -> bool { - self.mcp_servers.as_ref().is_none_or(BTreeMap::is_empty) + self.mcp_servers.is_none() } } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 5ae2688036..c3b951cc42 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,4 +1,5 @@ mod cloud_requirements; +mod cloud_requirements_composition; mod config_requirements; pub mod config_toml; mod constraint; @@ -31,6 +32,10 @@ pub const CONFIG_TOML_FILE: &str = "config.toml"; pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; +pub use cloud_requirements_composition::CloudRequirementsCompositionError; +pub use cloud_requirements_composition::CloudRequirementsFragment; +pub use cloud_requirements_composition::CloudRequirementsFragmentSource; +pub use cloud_requirements_composition::compose_cloud_requirements; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 3ddf8f2387..6eafbd3d63 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -277,6 +277,9 @@ fn fallback_managed_hooks_source_path( Some(RequirementSource::CloudRequirements) => { synthetic_layer_path("/requirements.toml") } + Some(RequirementSource::EnterpriseManaged { id, name }) => synthetic_layer_path(&format!( + "/requirements.toml" + )), Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { synthetic_layer_path("/managed_config.toml") } @@ -606,7 +609,8 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, + Some(RequirementSource::CloudRequirements) + | Some(RequirementSource::EnterpriseManaged { .. }) => HookSource::CloudRequirements, Some(RequirementSource::Unknown) | None => HookSource::Unknown, } }