mirror of
https://github.com/openai/codex.git
synced 2026-05-28 15:00:16 +00:00
Compose cloud-managed requirements fragments
Add cloud_requirements_composition to parse backend-selected enterprise-managed requirements fragments independently, apply remote_sandbox_config per fragment, and fold the parsed layers in backend priority order into ConfigRequirementsWithSources. Export the composer and fragment types from codex-config.
Add RequirementSource::EnterpriseManaged { id, name } so diagnostics can name the exact managed layer when a single fragment owns a value. When multiple fragments contribute to one composed field, provenance collapses back to CloudRequirements.
Merge strategy: cloud fragments are ordered highest priority first. The default rule is priority-first: lower-priority layers fill gaps, but do not normally override an already configured higher-priority value.
Field policies: top-level scalar/list fields are first configured value wins, including allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, allow_managed_hooks_only, enforce_residency, and guardian_policy_config. Blank guardian_policy_config is treated as unset.
remote_sandbox_config is applied inside each fragment before merging, then discarded. Feature requirements merge by key, with the first value per feature winning.
Hooks append event arrays in bundle order. The active platform managed hook dir is a singleton and conflicting values fail closed. The inactive platform hook dir is first-filled so OS-specific layers can coexist.
MCP server requirements merge as keyed unions. Unique server ids are accumulated, identical duplicate definitions are allowed, and conflicting duplicate server ids fail closed. Plugin-scoped MCP servers follow the same rule under each plugin id.
Apps reuse the existing requirements behavior: app enabled=false is disable-wins across layers, while tool approval settings keep the higher-priority value when present and lower-priority layers fill missing tool settings.
Rules prefix_rules append in bundle order. Network scalar fields are first-wins, while network domains and unix_sockets are keyed unions: unique keys are accumulated and duplicate keys keep the highest-priority value. Filesystem permissions deny_read is a stable union with deduplication.
Add composition-boundary tests covering parse diagnostics, source provenance, first-wins scalars, per-fragment remote sandbox matching, feature key precedence, hook append/conflict behavior, MCP conflict behavior, app disable-wins reuse, network keyed union semantics, rules appending, and filesystem deny_read dedupe.
This commit is contained in:
336
codex-rs/config/src/cloud_requirements_composition.rs
Normal file
336
codex-rs/config/src/cloud_requirements_composition.rs
Normal file
@@ -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<Item = CloudRequirementsFragment>,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, CloudRequirementsCompositionError> {
|
||||
let hostname = crate::host_name();
|
||||
compose_cloud_requirements_for_hostname(fragments, hostname.as_deref())
|
||||
}
|
||||
|
||||
fn compose_cloud_requirements_for_hostname(
|
||||
fragments: impl IntoIterator<Item = CloudRequirementsFragment>,
|
||||
hostname: Option<&str>,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, 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<Item = CloudRequirementsFragment>,
|
||||
hostname: Option<&str>,
|
||||
hook_directory_field: HookDirectoryField,
|
||||
) -> Result<Option<ConfigRequirementsWithSources>, 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<FeatureRequirementsToml>,
|
||||
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<AppsRequirementsToml>,
|
||||
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<RequirementsExecPolicyToml>,
|
||||
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<Option<ConfigRequirementsWithSources>, CloudRequirementsCompositionError> {
|
||||
let output_is_empty = self.output.clone().into_toml().is_empty();
|
||||
Ok((!output_is_empty).then_some(self.output))
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_first<T>(
|
||||
target: &mut Option<Sourced<T>>,
|
||||
incoming: Option<T>,
|
||||
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<String>,
|
||||
) -> CloudRequirementsCompositionError {
|
||||
CloudRequirementsCompositionError::Conflict {
|
||||
field,
|
||||
existing_fragment,
|
||||
incoming_fragment,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
235
codex-rs/config/src/cloud_requirements_composition/hooks.rs
Normal file
235
codex-rs/config/src/cloud_requirements_composition/hooks.rs
Normal file
@@ -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<HookDirectoryField, CloudRequirementsFragmentSource>,
|
||||
}
|
||||
|
||||
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<Sourced<ManagedHooksRequirementsToml>>,
|
||||
incoming: Option<ManagedHooksRequirementsToml>,
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
incoming: Option<PathBuf>,
|
||||
incoming_source: &CloudRequirementsFragmentSource,
|
||||
) -> Result<bool, CloudRequirementsCompositionError> {
|
||||
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<PathBuf>,
|
||||
incoming: Option<PathBuf>,
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<T>(existing: &mut Vec<T>, mut incoming: Vec<T>) -> bool {
|
||||
let changed = !incoming.is_empty();
|
||||
existing.append(&mut incoming);
|
||||
changed
|
||||
}
|
||||
164
codex-rs/config/src/cloud_requirements_composition/mcp.rs
Normal file
164
codex-rs/config/src/cloud_requirements_composition/mcp.rs
Normal file
@@ -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<String, CloudRequirementsFragmentSource>,
|
||||
plugin_mcp_server_sources: BTreeMap<(String, String), CloudRequirementsFragmentSource>,
|
||||
}
|
||||
|
||||
impl McpMergeState {
|
||||
pub(super) fn merge_mcp_servers(
|
||||
&mut self,
|
||||
target: &mut Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
|
||||
incoming: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
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<Sourced<BTreeMap<String, PluginRequirementsToml>>>,
|
||||
incoming: Option<BTreeMap<String, PluginRequirementsToml>>,
|
||||
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<String, PluginRequirementsToml>,
|
||||
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<bool, CloudRequirementsCompositionError> {
|
||||
// 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)
|
||||
}
|
||||
139
codex-rs/config/src/cloud_requirements_composition/network.rs
Normal file
139
codex-rs/config/src/cloud_requirements_composition/network.rs
Normal file
@@ -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<Sourced<NetworkRequirementsToml>>,
|
||||
incoming: Option<NetworkRequirementsToml>,
|
||||
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<T>(target: &mut Option<T>, incoming: Option<T>) -> bool {
|
||||
if target.is_none()
|
||||
&& let Some(value) = incoming
|
||||
{
|
||||
*target = Some(value);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn merge_domain_permissions(
|
||||
existing: &mut Option<NetworkDomainPermissionsToml>,
|
||||
incoming: Option<NetworkDomainPermissionsToml>,
|
||||
) -> 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<NetworkUnixSocketPermissionsToml>,
|
||||
incoming: Option<NetworkUnixSocketPermissionsToml>,
|
||||
) -> 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
|
||||
}
|
||||
@@ -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<Sourced<PermissionsRequirementsToml>>,
|
||||
incoming: Option<PermissionsRequirementsToml>,
|
||||
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
|
||||
}
|
||||
910
codex-rs/config/src/cloud_requirements_composition/tests.rs
Normal file
910
codex-rs/config/src/cloud_requirements_composition/tests.rs
Normal file
@@ -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<CloudRequirementsFragment>,
|
||||
) -> Result<Option<ConfigRequirementsToml>, CloudRequirementsCompositionError> {
|
||||
Ok(
|
||||
compose_cloud_requirements_for_hostname(fragments, /*hostname*/ None)?
|
||||
.map(ConfigRequirementsWithSources::into_toml),
|
||||
)
|
||||
}
|
||||
|
||||
fn compose_with_hook_directory_field(
|
||||
fragments: Vec<CloudRequirementsFragment>,
|
||||
hook_directory_field: HookDirectoryField,
|
||||
) -> Result<Option<ConfigRequirementsToml>, 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<_>>(),
|
||||
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"));
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -277,6 +277,9 @@ fn fallback_managed_hooks_source_path(
|
||||
Some(RequirementSource::CloudRequirements) => {
|
||||
synthetic_layer_path("<cloud-requirements>/requirements.toml")
|
||||
}
|
||||
Some(RequirementSource::EnterpriseManaged { id, name }) => synthetic_layer_path(&format!(
|
||||
"<enterprise-managed:{name}:{id}>/requirements.toml"
|
||||
)),
|
||||
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
|
||||
synthetic_layer_path("<legacy-managed-config.toml-mdm>/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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user