config requirements: improve requirement error messages (#8843)
**Before:**
```
Error loading configuration: value `Never` is not in the allowed set [OnRequest]
```
**After:**
```
Error loading configuration: invalid value for `approval_policy`: `Never` is not in the
allowed set [OnRequest] (set by MDM com.openai.codex:requirements_toml_base64)
```
Done by introducing a new struct `ConfigRequirementsWithSources` onto
which we `merge_unset_fields` now. Also introduces a pair of requirement
value and its `RequirementSource` (inspired by `ConfigLayerSource`):
```rust
pub struct Sourced<T> {
pub value: T,
pub source: RequirementSource,
}
```
This commit is contained in:
parent
484f6f4c26
commit
932a5a446f
6 changed files with 378 additions and 140 deletions
|
|
@ -1,25 +1,26 @@
|
|||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config_loader::RequirementSource;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ConstraintError {
|
||||
#[error("value `{candidate}` is not in the allowed set {allowed}")]
|
||||
InvalidValue { candidate: String, allowed: String },
|
||||
#[error(
|
||||
"invalid value for `{field_name}`: `{candidate}` is not in the allowed set {allowed} (set by {requirement_source})"
|
||||
)]
|
||||
InvalidValue {
|
||||
field_name: &'static str,
|
||||
candidate: String,
|
||||
allowed: String,
|
||||
requirement_source: RequirementSource,
|
||||
},
|
||||
|
||||
#[error("field `{field_name}` cannot be empty")]
|
||||
EmptyField { field_name: String },
|
||||
}
|
||||
|
||||
impl ConstraintError {
|
||||
pub fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> Self {
|
||||
Self::InvalidValue {
|
||||
candidate: candidate.into(),
|
||||
allowed: allowed.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_field(field_name: impl Into<String>) -> Self {
|
||||
Self::EmptyField {
|
||||
field_name: field_name.into(),
|
||||
|
|
@ -63,24 +64,6 @@ impl<T: Send + Sync> Constrained<T> {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn allow_only(value: T) -> Self
|
||||
where
|
||||
T: PartialEq + Send + Sync + fmt::Debug + Clone + 'static,
|
||||
{
|
||||
#[expect(clippy::expect_used)]
|
||||
Self::new(value.clone(), move |candidate| {
|
||||
if *candidate == value {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
format!("{candidate:?}"),
|
||||
format!("{value:?}"),
|
||||
))
|
||||
}
|
||||
})
|
||||
.expect("initial value should always be valid")
|
||||
}
|
||||
|
||||
/// Allow any value of T, using T's Default as the initial value.
|
||||
pub fn allow_any_from_default() -> Self
|
||||
where
|
||||
|
|
@ -89,22 +72,6 @@ impl<T: Send + Sync> Constrained<T> {
|
|||
Self::allow_any(T::default())
|
||||
}
|
||||
|
||||
pub fn allow_values(initial_value: T, allowed: Vec<T>) -> ConstraintResult<Self>
|
||||
where
|
||||
T: PartialEq + Send + Sync + fmt::Debug + 'static,
|
||||
{
|
||||
Self::new(initial_value, move |candidate| {
|
||||
if allowed.contains(candidate) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
format!("{candidate:?}"),
|
||||
format!("{allowed:?}"),
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &T {
|
||||
&self.value
|
||||
}
|
||||
|
|
@ -154,6 +121,15 @@ mod tests {
|
|||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> ConstraintError {
|
||||
ConstraintError::InvalidValue {
|
||||
field_name: "<unknown>",
|
||||
candidate: candidate.into(),
|
||||
allowed: allowed.into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_allow_any_accepts_any_value() {
|
||||
let mut constrained = Constrained::allow_any(5);
|
||||
|
|
@ -173,17 +149,11 @@ mod tests {
|
|||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
Err(invalid_value(value.to_string(), "positive values"))
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(ConstraintError::invalid_value("0", "positive values"))
|
||||
);
|
||||
assert_eq!(result, Err(invalid_value("0", "positive values")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -192,10 +162,7 @@ mod tests {
|
|||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
Err(invalid_value(value.to_string(), "positive values"))
|
||||
}
|
||||
})
|
||||
.expect("initial value should be accepted");
|
||||
|
|
@ -203,7 +170,7 @@ mod tests {
|
|||
let err = constrained
|
||||
.set(-5)
|
||||
.expect_err("negative values should be rejected");
|
||||
assert_eq!(err, ConstraintError::invalid_value("-5", "positive values"));
|
||||
assert_eq!(err, invalid_value("-5", "positive values"));
|
||||
assert_eq!(constrained.value(), 1);
|
||||
}
|
||||
|
||||
|
|
@ -213,10 +180,7 @@ mod tests {
|
|||
if *value > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
value.to_string(),
|
||||
"positive values",
|
||||
))
|
||||
Err(invalid_value(value.to_string(), "positive values"))
|
||||
}
|
||||
})
|
||||
.expect("initial value should be accepted");
|
||||
|
|
@ -227,7 +191,7 @@ mod tests {
|
|||
let err = constrained
|
||||
.can_set(&-1)
|
||||
.expect_err("can_set should reject negative value");
|
||||
assert_eq!(err, ConstraintError::invalid_value("-1", "positive values"));
|
||||
assert_eq!(err, invalid_value("-1", "positive values"));
|
||||
assert_eq!(constrained.value(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,42 @@
|
|||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::fmt;
|
||||
|
||||
use crate::config::Constrained;
|
||||
use crate::config::ConstraintError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequirementSource {
|
||||
Unknown,
|
||||
MdmManagedPreferences { domain: String, key: String },
|
||||
SystemRequirementsToml { file: AbsolutePathBuf },
|
||||
LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf },
|
||||
LegacyManagedConfigTomlFromMdm,
|
||||
}
|
||||
|
||||
impl fmt::Display for RequirementSource {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RequirementSource::Unknown => write!(f, "<unspecified>"),
|
||||
RequirementSource::MdmManagedPreferences { domain, key } => {
|
||||
write!(f, "MDM {domain}:{key}")
|
||||
}
|
||||
RequirementSource::SystemRequirementsToml { file } => {
|
||||
write!(f, "{}", file.as_path().display())
|
||||
}
|
||||
RequirementSource::LegacyManagedConfigTomlFromFile { file } => {
|
||||
write!(f, "{}", file.as_path().display())
|
||||
}
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm => {
|
||||
write!(f, "MDM managed_config.toml (legacy)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized version of [`ConfigRequirementsToml`] after deserialization and
|
||||
/// normalization.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
|
@ -30,6 +61,75 @@ pub struct ConfigRequirementsToml {
|
|||
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
|
||||
}
|
||||
|
||||
/// Value paired with the requirement source it came from, for better error
|
||||
/// messages.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Sourced<T> {
|
||||
pub value: T,
|
||||
pub source: RequirementSource,
|
||||
}
|
||||
|
||||
impl<T> Sourced<T> {
|
||||
pub fn new(value: T, source: RequirementSource) -> Self {
|
||||
Self { value, source }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::ops::Deref for Sourced<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ConfigRequirementsWithSources {
|
||||
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
|
||||
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
|
||||
}
|
||||
|
||||
impl ConfigRequirementsWithSources {
|
||||
pub fn merge_unset_fields(&mut self, source: RequirementSource, other: ConfigRequirementsToml) {
|
||||
// For every field in `other` that is `Some`, if the corresponding field
|
||||
// in `self` is `None`, copy the value from `other` into `self`.
|
||||
macro_rules! fill_missing_take {
|
||||
($base:expr, $other:expr, $source:expr, { $($field:ident),+ $(,)? }) => {
|
||||
// Destructure without `..` so adding fields to `ConfigRequirementsToml`
|
||||
// forces this merge logic to be updated.
|
||||
let ConfigRequirementsToml { $($field: _,)+ } = &$other;
|
||||
|
||||
$(
|
||||
if $base.$field.is_none()
|
||||
&& let Some(value) = $other.$field.take()
|
||||
{
|
||||
$base.$field = Some(Sourced::new(value, $source.clone()));
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
let mut other = other;
|
||||
fill_missing_take!(
|
||||
self,
|
||||
other,
|
||||
source,
|
||||
{ allowed_approval_policies, allowed_sandbox_modes }
|
||||
);
|
||||
}
|
||||
|
||||
pub fn into_toml(self) -> ConfigRequirementsToml {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
} = self;
|
||||
ConfigRequirementsToml {
|
||||
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Currently, `external-sandbox` is not supported in config.toml, but it is
|
||||
/// supported through programmatic use.
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
|
|
@ -61,41 +161,38 @@ impl ConfigRequirementsToml {
|
|||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none()
|
||||
}
|
||||
|
||||
/// For every field in `other` that is `Some`, if the corresponding field in
|
||||
/// `self` is `None`, copy the value from `other` into `self`.
|
||||
pub fn merge_unset_fields(&mut self, mut other: ConfigRequirementsToml) {
|
||||
macro_rules! fill_missing_take {
|
||||
($base:expr, $other:expr, { $($field:ident),+ $(,)? }) => {
|
||||
$(
|
||||
if $base.$field.is_none() {
|
||||
if let Some(value) = $other.$field.take() {
|
||||
$base.$field = Some(value);
|
||||
}
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
fill_missing_take!(self, other, { allowed_approval_policies, allowed_sandbox_modes });
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ConfigRequirementsToml> for ConfigRequirements {
|
||||
impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
type Error = ConstraintError;
|
||||
|
||||
fn try_from(toml: ConfigRequirementsToml) -> Result<Self, Self::Error> {
|
||||
let ConfigRequirementsToml {
|
||||
fn try_from(toml: ConfigRequirementsWithSources) -> Result<Self, Self::Error> {
|
||||
let ConfigRequirementsWithSources {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
} = toml;
|
||||
|
||||
let approval_policy: Constrained<AskForApproval> = match allowed_approval_policies {
|
||||
Some(policies) => {
|
||||
if let Some(first) = policies.first() {
|
||||
Constrained::allow_values(*first, policies)?
|
||||
} else {
|
||||
Some(Sourced {
|
||||
value: policies,
|
||||
source: requirement_source,
|
||||
}) => {
|
||||
let Some(initial_value) = policies.first().copied() else {
|
||||
return Err(ConstraintError::empty_field("allowed_approval_policies"));
|
||||
}
|
||||
};
|
||||
|
||||
Constrained::new(initial_value, move |candidate| {
|
||||
if policies.contains(candidate) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: format!("{candidate:?}"),
|
||||
allowed: format!("{policies:?}"),
|
||||
requirement_source: requirement_source.clone(),
|
||||
})
|
||||
}
|
||||
})?
|
||||
}
|
||||
None => Constrained::allow_any_from_default(),
|
||||
};
|
||||
|
|
@ -109,12 +206,17 @@ impl TryFrom<ConfigRequirementsToml> for ConfigRequirements {
|
|||
// format to allow specifying those parameters.
|
||||
let default_sandbox_policy = SandboxPolicy::ReadOnly;
|
||||
let sandbox_policy: Constrained<SandboxPolicy> = match allowed_sandbox_modes {
|
||||
Some(modes) => {
|
||||
Some(Sourced {
|
||||
value: modes,
|
||||
source: requirement_source,
|
||||
}) => {
|
||||
if !modes.contains(&SandboxModeRequirement::ReadOnly) {
|
||||
return Err(ConstraintError::invalid_value(
|
||||
"allowed_sandbox_modes",
|
||||
"must include 'read-only' to allow any SandboxPolicy",
|
||||
));
|
||||
return Err(ConstraintError::InvalidValue {
|
||||
field_name: "allowed_sandbox_modes",
|
||||
candidate: format!("{modes:?}"),
|
||||
allowed: "must include 'read-only' to allow any SandboxPolicy".to_string(),
|
||||
requirement_source,
|
||||
});
|
||||
};
|
||||
|
||||
Constrained::new(default_sandbox_policy, move |candidate| {
|
||||
|
|
@ -131,10 +233,12 @@ impl TryFrom<ConfigRequirementsToml> for ConfigRequirements {
|
|||
if modes.contains(&mode) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ConstraintError::invalid_value(
|
||||
format!("{candidate:?}"),
|
||||
format!("{modes:?}"),
|
||||
))
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: format!("{mode:?}"),
|
||||
allowed: format!("{modes:?}"),
|
||||
requirement_source: requirement_source.clone(),
|
||||
})
|
||||
}
|
||||
})?
|
||||
}
|
||||
|
|
@ -156,45 +260,168 @@ mod tests {
|
|||
use pretty_assertions::assert_eq;
|
||||
use toml::from_str;
|
||||
|
||||
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsToml {
|
||||
allowed_approval_policies,
|
||||
allowed_sandbox_modes,
|
||||
} = toml;
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: allowed_approval_policies
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_sandbox_modes: allowed_sandbox_modes
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unset_fields_only_fills_missing_values() -> Result<()> {
|
||||
fn merge_unset_fields_copies_every_field_and_sets_sources() {
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
|
||||
let allowed_approval_policies = vec![AskForApproval::UnlessTrusted, AskForApproval::Never];
|
||||
let allowed_sandbox_modes = vec![
|
||||
SandboxModeRequirement::WorkspaceWrite,
|
||||
SandboxModeRequirement::DangerFullAccess,
|
||||
];
|
||||
|
||||
// Intentionally constructed without `..Default::default()` so adding a new field to
|
||||
// `ConfigRequirementsToml` forces this test to be updated.
|
||||
let other = ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(allowed_approval_policies.clone()),
|
||||
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
|
||||
};
|
||||
|
||||
target.merge_unset_fields(source.clone(), other);
|
||||
|
||||
assert_eq!(
|
||||
target,
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: Some(Sourced::new(
|
||||
allowed_approval_policies,
|
||||
source.clone()
|
||||
)),
|
||||
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unset_fields_fills_missing_values() -> Result<()> {
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let mut empty_target: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
# intentionally left unset
|
||||
"#,
|
||||
)?;
|
||||
empty_target.merge_unset_fields(source.clone());
|
||||
assert_eq!(
|
||||
empty_target.allowed_approval_policies,
|
||||
Some(vec![AskForApproval::OnRequest])
|
||||
);
|
||||
let source_location = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.codex".to_string(),
|
||||
key: "allowed_approval_policies".to_string(),
|
||||
};
|
||||
|
||||
let mut populated_target: ConfigRequirementsToml = from_str(
|
||||
let mut empty_target = ConfigRequirementsWithSources::default();
|
||||
empty_target.merge_unset_fields(source_location.clone(), source);
|
||||
assert_eq!(
|
||||
empty_target,
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: Some(Sourced::new(
|
||||
vec![AskForApproval::OnRequest],
|
||||
source_location,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_unset_fields_does_not_overwrite_existing_values() -> Result<()> {
|
||||
let existing_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
|
||||
let mut populated_target = ConfigRequirementsWithSources::default();
|
||||
let populated_requirements: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["never"]
|
||||
"#,
|
||||
)?;
|
||||
populated_target.merge_unset_fields(source);
|
||||
populated_target.merge_unset_fields(existing_source.clone(), populated_requirements);
|
||||
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
"#,
|
||||
)?;
|
||||
let source_location = RequirementSource::MdmManagedPreferences {
|
||||
domain: "com.codex".to_string(),
|
||||
key: "allowed_approval_policies".to_string(),
|
||||
};
|
||||
populated_target.merge_unset_fields(source_location, source);
|
||||
|
||||
assert_eq!(
|
||||
populated_target.allowed_approval_policies,
|
||||
Some(vec![AskForApproval::Never])
|
||||
populated_target,
|
||||
ConfigRequirementsWithSources {
|
||||
allowed_approval_policies: Some(Sourced::new(
|
||||
vec![AskForApproval::Never],
|
||||
existing_source,
|
||||
)),
|
||||
allowed_sandbox_modes: None,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_error_includes_requirement_source() -> Result<()> {
|
||||
let source: ConfigRequirementsToml = from_str(
|
||||
r#"
|
||||
allowed_approval_policies = ["on-request"]
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let requirements_toml_file = if cfg!(windows) {
|
||||
"C:\\etc\\codex\\requirements.toml"
|
||||
} else {
|
||||
"/etc/codex/requirements.toml"
|
||||
};
|
||||
let requirements_toml_file = AbsolutePathBuf::from_absolute_path(requirements_toml_file)?;
|
||||
let source_location = RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file,
|
||||
};
|
||||
|
||||
let mut target = ConfigRequirementsWithSources::default();
|
||||
target.merge_unset_fields(source_location.clone(), source);
|
||||
let requirements = ConfigRequirements::try_from(target)?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.approval_policy.can_set(&AskForApproval::Never),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "Never".into(),
|
||||
allowed: "[OnRequest]".into(),
|
||||
requirement_source: source_location.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
requirements
|
||||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::DangerFullAccess),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "DangerFullAccess".into(),
|
||||
allowed: "[ReadOnly]".into(),
|
||||
requirement_source: source_location,
|
||||
})
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_allowed_approval_policies() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
allowed_approval_policies = ["untrusted", "on-request"]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements = ConfigRequirements::try_from(config)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.approval_policy.value(),
|
||||
|
|
@ -212,8 +439,10 @@ mod tests {
|
|||
.approval_policy
|
||||
.can_set(&AskForApproval::OnFailure),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "OnFailure".into(),
|
||||
allowed: "[UnlessTrusted, OnRequest]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -225,8 +454,10 @@ mod tests {
|
|||
assert_eq!(
|
||||
requirements.approval_policy.can_set(&AskForApproval::Never),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "approval_policy",
|
||||
candidate: "Never".into(),
|
||||
allowed: "[UnlessTrusted, OnRequest]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
|
|
@ -245,7 +476,7 @@ mod tests {
|
|||
allowed_sandbox_modes = ["read-only", "workspace-write"]
|
||||
"#;
|
||||
let config: ConfigRequirementsToml = from_str(toml_str)?;
|
||||
let requirements = ConfigRequirements::try_from(config)?;
|
||||
let requirements: ConfigRequirements = with_unknown_source(config).try_into()?;
|
||||
|
||||
let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
|
||||
assert!(
|
||||
|
|
@ -270,8 +501,10 @@ mod tests {
|
|||
.sandbox_policy
|
||||
.can_set(&SandboxPolicy::DangerFullAccess),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "DangerFullAccess".into(),
|
||||
allowed: "[ReadOnly, WorkspaceWrite]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
@ -281,8 +514,10 @@ mod tests {
|
|||
network_access: NetworkAccess::Restricted,
|
||||
}),
|
||||
Err(ConstraintError::InvalidValue {
|
||||
candidate: "ExternalSandbox { network_access: Restricted }".into(),
|
||||
field_name: "sandbox_mode",
|
||||
candidate: "ExternalSandbox".into(),
|
||||
allowed: "[ReadOnly, WorkspaceWrite]".into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use super::config_requirements::ConfigRequirementsToml;
|
||||
use super::config_requirements::ConfigRequirementsWithSources;
|
||||
use super::config_requirements::RequirementSource;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use core_foundation::base::TCFType;
|
||||
|
|
@ -13,6 +15,13 @@ const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
|
|||
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
|
||||
const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64";
|
||||
|
||||
pub(super) fn managed_preferences_requirements_source() -> RequirementSource {
|
||||
RequirementSource::MdmManagedPreferences {
|
||||
domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(),
|
||||
key: MANAGED_PREFERENCES_REQUIREMENTS_KEY.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_config_layer(
|
||||
override_base64: Option<&str>,
|
||||
) -> io::Result<Option<TomlValue>> {
|
||||
|
|
@ -47,21 +56,26 @@ fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
|
|||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_requirements_toml(
|
||||
target: &mut ConfigRequirementsToml,
|
||||
target: &mut ConfigRequirementsWithSources,
|
||||
override_base64: Option<&str>,
|
||||
) -> io::Result<()> {
|
||||
if let Some(encoded) = override_base64 {
|
||||
let trimmed = encoded.trim();
|
||||
if !trimmed.is_empty() {
|
||||
target.merge_unset_fields(parse_managed_requirements_base64(trimmed)?);
|
||||
if trimmed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
target.merge_unset_fields(
|
||||
managed_preferences_requirements_source(),
|
||||
parse_managed_requirements_base64(trimmed)?,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match task::spawn_blocking(load_managed_admin_requirements).await {
|
||||
Ok(result) => {
|
||||
if let Some(requirements) = result? {
|
||||
target.merge_unset_fields(requirements);
|
||||
target.merge_unset_fields(managed_preferences_requirements_source(), requirements);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ mod tests;
|
|||
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigToml;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::layer_io::LoadedConfigLayers;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
|
|
@ -25,6 +26,7 @@ use toml::Value as TomlValue;
|
|||
|
||||
pub use config_requirements::ConfigRequirements;
|
||||
pub use config_requirements::ConfigRequirementsToml;
|
||||
pub use config_requirements::RequirementSource;
|
||||
pub use config_requirements::SandboxModeRequirement;
|
||||
pub use merge::merge_toml_values;
|
||||
pub use state::ConfigLayerEntry;
|
||||
|
|
@ -77,7 +79,7 @@ pub async fn load_config_layers_state(
|
|||
cli_overrides: &[(String, TomlValue)],
|
||||
overrides: LoaderOverrides,
|
||||
) -> io::Result<ConfigLayerStack> {
|
||||
let mut config_requirements_toml = ConfigRequirementsToml::default();
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::load_managed_admin_requirements_toml(
|
||||
|
|
@ -202,9 +204,11 @@ pub async fn load_config_layers_state(
|
|||
));
|
||||
}
|
||||
|
||||
let requirements_toml = config_requirements_toml.clone();
|
||||
let requirements = config_requirements_toml.try_into()?;
|
||||
ConfigLayerStack::new(layers, requirements, requirements_toml)
|
||||
ConfigLayerStack::new(
|
||||
layers,
|
||||
config_requirements_toml.clone().try_into()?,
|
||||
config_requirements_toml.into_toml(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Attempts to load a config.toml file from `config_toml`.
|
||||
|
|
@ -256,9 +260,11 @@ async fn load_config_toml_for_required_layer(
|
|||
/// If available, apply requirements from `/etc/codex/requirements.toml` to
|
||||
/// `config_requirements_toml` by filling in any unset fields.
|
||||
async fn load_requirements_toml(
|
||||
config_requirements_toml: &mut ConfigRequirementsToml,
|
||||
config_requirements_toml: &mut ConfigRequirementsWithSources,
|
||||
requirements_toml_file: impl AsRef<Path>,
|
||||
) -> io::Result<()> {
|
||||
let requirements_toml_file =
|
||||
AbsolutePathBuf::from_absolute_path(requirements_toml_file.as_ref())?;
|
||||
match tokio::fs::read_to_string(&requirements_toml_file).await {
|
||||
Ok(contents) => {
|
||||
let requirements_config: ConfigRequirementsToml =
|
||||
|
|
@ -271,7 +277,12 @@ async fn load_requirements_toml(
|
|||
),
|
||||
)
|
||||
})?;
|
||||
config_requirements_toml.merge_unset_fields(requirements_config);
|
||||
config_requirements_toml.merge_unset_fields(
|
||||
RequirementSource::SystemRequirementsToml {
|
||||
file: requirements_toml_file.clone(),
|
||||
},
|
||||
requirements_config,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() != io::ErrorKind::NotFound {
|
||||
|
|
@ -290,7 +301,7 @@ async fn load_requirements_toml(
|
|||
}
|
||||
|
||||
async fn load_requirements_from_legacy_scheme(
|
||||
config_requirements_toml: &mut ConfigRequirementsToml,
|
||||
config_requirements_toml: &mut ConfigRequirementsWithSources,
|
||||
loaded_config_layers: LoadedConfigLayers,
|
||||
) -> io::Result<()> {
|
||||
// In this implementation, earlier layers cannot be overwritten by later
|
||||
|
|
@ -300,12 +311,16 @@ async fn load_requirements_from_legacy_scheme(
|
|||
managed_config,
|
||||
managed_config_from_mdm,
|
||||
} = loaded_config_layers;
|
||||
for config in [
|
||||
managed_config_from_mdm,
|
||||
managed_config.map(|c| c.managed_config),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
|
||||
for (source, config) in managed_config_from_mdm
|
||||
.map(|config| (RequirementSource::LegacyManagedConfigTomlFromMdm, config))
|
||||
.into_iter()
|
||||
.chain(managed_config.map(|c| {
|
||||
(
|
||||
RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
|
||||
c.managed_config,
|
||||
)
|
||||
}))
|
||||
{
|
||||
let legacy_config: LegacyManagedConfigToml =
|
||||
config.try_into().map_err(|err: toml::de::Error| {
|
||||
|
|
@ -316,7 +331,7 @@ async fn load_requirements_from_legacy_scheme(
|
|||
})?;
|
||||
|
||||
let new_requirements_toml = ConfigRequirementsToml::from(legacy_config);
|
||||
config_requirements_toml.merge_unset_fields(new_requirements_toml);
|
||||
config_requirements_toml.merge_unset_fields(source, new_requirements_toml);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::config::ConfigBuilder;
|
|||
use crate::config::ConfigOverrides;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsToml;
|
||||
use crate::config_loader::config_requirements::ConfigRequirementsWithSources;
|
||||
use crate::config_loader::fingerprint::version_for_toml;
|
||||
use crate::config_loader::load_requirements_toml;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
|
@ -315,11 +315,14 @@ allowed_approval_policies = ["never", "on-request"]
|
|||
)
|
||||
.await?;
|
||||
|
||||
let mut config_requirements_toml = ConfigRequirementsToml::default();
|
||||
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
||||
load_requirements_toml(&mut config_requirements_toml, &requirements_file).await?;
|
||||
|
||||
assert_eq!(
|
||||
config_requirements_toml.allowed_approval_policies,
|
||||
config_requirements_toml
|
||||
.allowed_approval_policies
|
||||
.as_deref()
|
||||
.cloned(),
|
||||
Some(vec![AskForApproval::Never, AskForApproval::OnRequest])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use codex_core::config::Config;
|
|||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::config::ConstraintError;
|
||||
use codex_core::config_loader::RequirementSource;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
|
|
@ -85,6 +86,15 @@ async fn test_config() -> Config {
|
|||
.expect("config")
|
||||
}
|
||||
|
||||
fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> ConstraintError {
|
||||
ConstraintError::InvalidValue {
|
||||
field_name: "<unknown>",
|
||||
candidate: candidate.into(),
|
||||
allowed: allowed.into(),
|
||||
requirement_source: RequirementSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(percent: f64) -> RateLimitSnapshot {
|
||||
RateLimitSnapshot {
|
||||
primary: Some(RateLimitWindow {
|
||||
|
|
@ -2303,7 +2313,7 @@ async fn approvals_popup_shows_disabled_presets() {
|
|||
chat.config.approval_policy =
|
||||
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||
AskForApproval::OnRequest => Ok(()),
|
||||
_ => Err(ConstraintError::invalid_value(
|
||||
_ => Err(invalid_value(
|
||||
candidate.to_string(),
|
||||
"this message should be printed in the description",
|
||||
)),
|
||||
|
|
@ -2339,10 +2349,7 @@ async fn approvals_popup_navigation_skips_disabled() {
|
|||
chat.config.approval_policy =
|
||||
Constrained::new(AskForApproval::OnRequest, |candidate| match candidate {
|
||||
AskForApproval::OnRequest => Ok(()),
|
||||
_ => Err(ConstraintError::invalid_value(
|
||||
candidate.to_string(),
|
||||
"[on-request]",
|
||||
)),
|
||||
_ => Err(invalid_value(candidate.to_string(), "[on-request]")),
|
||||
})
|
||||
.expect("construct constrained approval policy");
|
||||
chat.open_approvals_popup();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue