mirror of
https://github.com/openai/codex.git
synced 2026-03-19 04:16:30 +03:00
Compare commits
8 Commits
latest-alp
...
codex/eva-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0611acc558 | ||
|
|
a79c547d0a | ||
|
|
86e6c7d35d | ||
|
|
1da20eed5c | ||
|
|
5b60e97fdc | ||
|
|
c1840122f5 | ||
|
|
fd52f0fa20 | ||
|
|
4bab77a2ae |
@@ -9,11 +9,13 @@ use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_path_to_error::Path as SerdePath;
|
||||
use serde_path_to_error::Segment as SerdeSegment;
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
use std::fmt::Write;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::Document;
|
||||
use toml_edit::Item;
|
||||
use toml_edit::Table;
|
||||
@@ -134,6 +136,70 @@ pub fn config_error_from_typed_toml<T: DeserializeOwned>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SanitizedTomlValue {
|
||||
pub value: TomlValue,
|
||||
pub dropped_entries: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn sanitize_toml_value<T: DeserializeOwned>(
|
||||
mut value: TomlValue,
|
||||
) -> io::Result<SanitizedTomlValue> {
|
||||
let mut dropped_entries = Vec::new();
|
||||
let mut seen_paths = HashSet::new();
|
||||
|
||||
loop {
|
||||
let contents = toml::to_string(&value).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("failed to serialize TOML while sanitizing: {err}"),
|
||||
)
|
||||
})?;
|
||||
let deserializer = toml::de::Deserializer::parse(&contents).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("failed to parse TOML while sanitizing: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let result: Result<T, _> = serde_path_to_error::deserialize(deserializer);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
return Ok(SanitizedTomlValue {
|
||||
value,
|
||||
dropped_entries,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let path_hint = err.path().clone();
|
||||
let toml_err: toml::de::Error = err.into_inner();
|
||||
let Some(recovery_path) = recovery_path_for_error(&path_hint) else {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, toml_err));
|
||||
};
|
||||
let recovery_path_display = display_recovery_path(&recovery_path);
|
||||
if !seen_paths.insert(recovery_path_display.clone()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"unable to recover from repeated invalid TOML path {recovery_path_display}: {toml_err}",
|
||||
),
|
||||
));
|
||||
}
|
||||
if !remove_value_at_recovery_path(&mut value, &recovery_path) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"unable to remove invalid TOML path {recovery_path_display}: {toml_err}",
|
||||
),
|
||||
));
|
||||
}
|
||||
prune_empty_containers(&mut value);
|
||||
dropped_entries.push(format!("{recovery_path_display}: {}", toml_err.message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn first_layer_config_error<T: DeserializeOwned>(
|
||||
layers: &ConfigLayerStack,
|
||||
config_toml_file: &str,
|
||||
@@ -323,6 +389,125 @@ fn span_for_config_path(contents: &str, path: &SerdePath) -> Option<std::ops::Ra
|
||||
span_for_path(contents, path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum RecoveryPathSegment {
|
||||
Key(String),
|
||||
Index(usize),
|
||||
}
|
||||
|
||||
fn recovery_path_for_error(path: &SerdePath) -> Option<Vec<RecoveryPathSegment>> {
|
||||
let mut recovery_path = Vec::new();
|
||||
let mut last_seq_recovery_len = None;
|
||||
|
||||
for segment in path.iter() {
|
||||
match segment {
|
||||
SerdeSegment::Map { key } => {
|
||||
recovery_path.push(RecoveryPathSegment::Key(key.to_string()));
|
||||
}
|
||||
SerdeSegment::Enum { variant } => {
|
||||
recovery_path.push(RecoveryPathSegment::Key(variant.to_string()));
|
||||
}
|
||||
SerdeSegment::Seq { index } => {
|
||||
recovery_path.push(RecoveryPathSegment::Index(*index));
|
||||
last_seq_recovery_len = Some(recovery_path.len());
|
||||
}
|
||||
SerdeSegment::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
if recovery_path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(len) = last_seq_recovery_len {
|
||||
recovery_path.truncate(len);
|
||||
}
|
||||
|
||||
Some(recovery_path)
|
||||
}
|
||||
|
||||
fn display_recovery_path(path: &[RecoveryPathSegment]) -> String {
|
||||
let mut rendered = String::new();
|
||||
|
||||
for segment in path {
|
||||
match segment {
|
||||
RecoveryPathSegment::Key(key) => {
|
||||
if !rendered.is_empty() {
|
||||
rendered.push('.');
|
||||
}
|
||||
rendered.push_str(key);
|
||||
}
|
||||
RecoveryPathSegment::Index(index) => {
|
||||
let _ = write!(rendered, "[{index}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn remove_value_at_recovery_path(value: &mut TomlValue, path: &[RecoveryPathSegment]) -> bool {
|
||||
remove_value_at_recovery_path_inner(value, path, 0)
|
||||
}
|
||||
|
||||
fn prune_empty_containers(value: &mut TomlValue) {
|
||||
let _ = prune_empty_containers_inner(value);
|
||||
}
|
||||
|
||||
fn remove_value_at_recovery_path_inner(
|
||||
value: &mut TomlValue,
|
||||
path: &[RecoveryPathSegment],
|
||||
index: usize,
|
||||
) -> bool {
|
||||
let Some(segment) = path.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let is_leaf = index + 1 == path.len();
|
||||
|
||||
match segment {
|
||||
RecoveryPathSegment::Key(key) => {
|
||||
let Some(table) = value.as_table_mut() else {
|
||||
return false;
|
||||
};
|
||||
if is_leaf {
|
||||
table.remove(key).is_some()
|
||||
} else {
|
||||
table.get_mut(key).is_some_and(|child| {
|
||||
remove_value_at_recovery_path_inner(child, path, index + 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
RecoveryPathSegment::Index(seq_index) => {
|
||||
let Some(array) = value.as_array_mut() else {
|
||||
return false;
|
||||
};
|
||||
if *seq_index >= array.len() {
|
||||
return false;
|
||||
}
|
||||
if is_leaf {
|
||||
array.remove(*seq_index);
|
||||
true
|
||||
} else {
|
||||
remove_value_at_recovery_path_inner(&mut array[*seq_index], path, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_empty_containers_inner(value: &mut TomlValue) -> bool {
|
||||
match value {
|
||||
TomlValue::Table(table) => {
|
||||
table.retain(|_, child| !prune_empty_containers_inner(child));
|
||||
table.is_empty()
|
||||
}
|
||||
TomlValue::Array(array) => {
|
||||
array.retain_mut(|child| !prune_empty_containers_inner(child));
|
||||
array.is_empty()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_features_table_path(path: &SerdePath) -> bool {
|
||||
let mut segments = path.iter();
|
||||
matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features")
|
||||
|
||||
@@ -34,6 +34,7 @@ pub use constraint::ConstraintError;
|
||||
pub use constraint::ConstraintResult;
|
||||
pub use diagnostics::ConfigError;
|
||||
pub use diagnostics::ConfigLoadError;
|
||||
pub use diagnostics::SanitizedTomlValue;
|
||||
pub use diagnostics::TextPosition;
|
||||
pub use diagnostics::TextRange;
|
||||
pub use diagnostics::config_error_from_toml;
|
||||
@@ -43,6 +44,7 @@ pub use diagnostics::first_layer_config_error_from_entries;
|
||||
pub use diagnostics::format_config_error;
|
||||
pub use diagnostics::format_config_error_with_source;
|
||||
pub use diagnostics::io_error_from_config_error;
|
||||
pub use diagnostics::sanitize_toml_value;
|
||||
pub use fingerprint::version_for_toml;
|
||||
pub use merge::merge_toml_values;
|
||||
pub use overrides::build_cli_overrides_layer;
|
||||
|
||||
@@ -646,6 +646,17 @@ impl ConfigBuilder {
|
||||
}
|
||||
let cli_overrides = cli_overrides.unwrap_or_default();
|
||||
let mut harness_overrides = harness_overrides.unwrap_or_default();
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
let loader_overrides = {
|
||||
let loader_overrides_provided = loader_overrides.is_some();
|
||||
let mut loader_overrides = loader_overrides.unwrap_or_default();
|
||||
if !loader_overrides_provided {
|
||||
loader_overrides.managed_preferences_base64 = Some(String::new());
|
||||
loader_overrides.macos_managed_config_requirements_base64 = Some(String::new());
|
||||
}
|
||||
loader_overrides
|
||||
};
|
||||
#[cfg(not(all(test, target_os = "macos")))]
|
||||
let loader_overrides = loader_overrides.unwrap_or_default();
|
||||
let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref());
|
||||
let cwd = match cwd_override {
|
||||
|
||||
@@ -7,8 +7,18 @@ use codex_app_server_protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_loader_overrides(managed_config_path: Option<PathBuf>) -> LoaderOverrides {
|
||||
LoaderOverrides {
|
||||
managed_config_path,
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_value_to_item_handles_nested_config_tables() {
|
||||
let config = r#"
|
||||
@@ -178,12 +188,7 @@ async fn read_includes_origins_and_layers() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(Some(managed_path.clone())),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -253,12 +258,7 @@ async fn write_value_reports_override() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(Some(managed_path.clone())),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -357,12 +357,7 @@ async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(Some(managed_path.clone())),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -422,12 +417,7 @@ async fn write_value_rejects_feature_requirement_conflict() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(None),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
|
||||
@@ -473,12 +463,7 @@ async fn write_value_rejects_profile_feature_requirement_conflict() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(None),
|
||||
CloudRequirementsLoader::new(async {
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
|
||||
@@ -535,12 +520,7 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
cli_overrides,
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(Some(managed_path.clone())),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
@@ -593,12 +573,7 @@ async fn write_value_reports_managed_override() {
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(managed_path.clone()),
|
||||
#[cfg(target_os = "macos")]
|
||||
managed_preferences_base64: None,
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
test_loader_overrides(Some(managed_path.clone())),
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use super::ConfigRequirementsToml;
|
||||
use super::ConfigRequirementsWithSources;
|
||||
use super::RequirementSource;
|
||||
use crate::config::ConfigToml;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use codex_config::sanitize_toml_value;
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::CFString;
|
||||
use core_foundation::string::CFStringRef;
|
||||
@@ -36,7 +38,7 @@ pub(crate) async fn load_managed_admin_config_layer(
|
||||
return if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
parse_managed_config_base64(trimmed).map(Some)
|
||||
parse_managed_config_base64(trimmed)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ fn load_managed_admin_config() -> io::Result<Option<ManagedAdminConfigLayer>> {
|
||||
.map(str::trim)
|
||||
.map(parse_managed_config_base64)
|
||||
.transpose()
|
||||
.map(Option::flatten)
|
||||
}
|
||||
|
||||
pub(crate) async fn load_managed_admin_requirements_toml(
|
||||
@@ -128,36 +131,154 @@ fn load_managed_preference(key_name: &str) -> io::Result<Option<String>> {
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_managed_config_base64(encoded: &str) -> io::Result<ManagedAdminConfigLayer> {
|
||||
let raw_toml = decode_managed_preferences_base64(encoded)?;
|
||||
fn parse_managed_config_base64(encoded: &str) -> io::Result<Option<ManagedAdminConfigLayer>> {
|
||||
let raw_toml = match decode_managed_preferences_base64(encoded) {
|
||||
Ok(raw_toml) => raw_toml,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Ignoring invalid MDM managed config payload",
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
match toml::from_str::<TomlValue>(&raw_toml) {
|
||||
Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer {
|
||||
config: TomlValue::Table(parsed),
|
||||
raw_toml,
|
||||
}),
|
||||
Ok(TomlValue::Table(parsed)) => {
|
||||
let sanitized = match sanitize_toml_value::<ConfigToml>(TomlValue::Table(parsed)) {
|
||||
Ok(sanitized) => sanitized,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Ignoring invalid MDM managed config payload",
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if let Some(dropped_entry) = sanitized
|
||||
.dropped_entries
|
||||
.iter()
|
||||
.find(|entry| is_invalid_security_managed_config_entry(entry))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed config from MDM: {dropped_entry}"),
|
||||
));
|
||||
}
|
||||
for dropped_entry in &sanitized.dropped_entries {
|
||||
tracing::warn!(
|
||||
dropped_entry = %dropped_entry,
|
||||
"Ignoring invalid MDM managed config entry",
|
||||
);
|
||||
}
|
||||
Ok(Some(ManagedAdminConfigLayer {
|
||||
config: sanitized.value,
|
||||
raw_toml,
|
||||
}))
|
||||
}
|
||||
Ok(other) => {
|
||||
tracing::error!("Managed config TOML must have a table at the root, found {other:?}",);
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"managed config root must be a table",
|
||||
))
|
||||
tracing::warn!(
|
||||
managed_value = ?other,
|
||||
"Ignoring invalid MDM managed config payload: root must be a table",
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to parse managed config TOML: {err}");
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"Ignoring invalid MDM managed config payload",
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_managed_requirements_base64(encoded: &str) -> io::Result<ConfigRequirementsToml> {
|
||||
toml::from_str::<ConfigRequirementsToml>(&decode_managed_preferences_base64(encoded)?).map_err(
|
||||
|err| {
|
||||
tracing::error!("Failed to parse managed requirements TOML: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
},
|
||||
fn is_invalid_security_managed_config_entry(dropped_entry: &str) -> bool {
|
||||
matches!(
|
||||
managed_entry_leaf_key(dropped_entry),
|
||||
"approval_policy" | "sandbox_mode"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_managed_requirements_base64(encoded: &str) -> io::Result<ConfigRequirementsToml> {
|
||||
let source = managed_preferences_requirements_source();
|
||||
let raw_toml = decode_managed_preferences_base64(encoded).map_err(|err| {
|
||||
io::Error::new(
|
||||
err.kind(),
|
||||
format!("Error parsing managed requirements from {source}: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let parsed = toml::from_str::<TomlValue>(&raw_toml).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed requirements from {source}: {err}"),
|
||||
)
|
||||
})?;
|
||||
let TomlValue::Table(parsed) = parsed else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed requirements from {source}: root must be a table"),
|
||||
));
|
||||
};
|
||||
|
||||
let sanitized = sanitize_toml_value::<ConfigRequirementsToml>(TomlValue::Table(parsed))
|
||||
.map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed requirements from {source}: {err}"),
|
||||
)
|
||||
})?;
|
||||
if let Some(dropped_entry) = sanitized
|
||||
.dropped_entries
|
||||
.iter()
|
||||
.find(|entry| is_invalid_security_managed_requirements_entry(entry))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed security controls from {source}: {dropped_entry}"),
|
||||
));
|
||||
}
|
||||
for dropped_entry in &sanitized.dropped_entries {
|
||||
tracing::warn!(
|
||||
dropped_entry = %dropped_entry,
|
||||
"Ignoring invalid MDM managed requirements entry",
|
||||
);
|
||||
}
|
||||
|
||||
sanitized.value.try_into().map_err(|err: toml::de::Error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing managed requirements from {source}: {err}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_invalid_security_managed_requirements_entry(dropped_entry: &str) -> bool {
|
||||
matches!(
|
||||
managed_entry_top_level_key(dropped_entry),
|
||||
"allowed_approval_policies" | "allowed_sandbox_modes" | "allowed_web_search_modes"
|
||||
)
|
||||
}
|
||||
|
||||
fn managed_entry_top_level_key(dropped_entry: &str) -> &str {
|
||||
let path = managed_entry_path(dropped_entry);
|
||||
path.split(['.', '[']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
fn managed_entry_leaf_key(dropped_entry: &str) -> &str {
|
||||
let path = managed_entry_path(dropped_entry);
|
||||
let leaf = path.rsplit('.').next().unwrap_or(path);
|
||||
leaf.split('[').next().unwrap_or(leaf)
|
||||
}
|
||||
|
||||
fn managed_entry_path(dropped_entry: &str) -> &str {
|
||||
let path = dropped_entry
|
||||
.split_once(':')
|
||||
.map_or(dropped_entry, |(path, _)| path)
|
||||
.trim();
|
||||
path
|
||||
}
|
||||
|
||||
fn decode_managed_preferences_base64(encoded: &str) -> io::Result<String> {
|
||||
String::from_utf8(BASE64_STANDARD.decode(encoded.as_bytes()).map_err(|err| {
|
||||
tracing::error!("Failed to decode managed value as base64: {err}",);
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::git_info::resolve_root_git_project_for_trust;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::ConfigRequirementsWithSources;
|
||||
use codex_config::sanitize_toml_value;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -149,20 +150,10 @@ pub async fn load_config_layers_state(
|
||||
|
||||
let mut layers = Vec::<ConfigLayerEntry>::new();
|
||||
|
||||
let cli_overrides_layer = if cli_overrides.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let cli_overrides_layer = build_cli_overrides_layer(cli_overrides);
|
||||
let base_dir = cwd
|
||||
.as_ref()
|
||||
.map(AbsolutePathBuf::as_path)
|
||||
.unwrap_or(codex_home);
|
||||
Some(resolve_relative_paths_in_config_toml(
|
||||
cli_overrides_layer,
|
||||
base_dir,
|
||||
)?)
|
||||
};
|
||||
|
||||
let session_flags_base_dir = cwd
|
||||
.as_ref()
|
||||
.map(|cwd| cwd.as_path().to_path_buf())
|
||||
.unwrap_or_else(|| codex_home.to_path_buf());
|
||||
// Include an entry for the "system" config folder, loading its config.toml,
|
||||
// if it exists.
|
||||
let system_config_toml_file = system_config_toml_file()?;
|
||||
@@ -193,12 +184,27 @@ pub async fn load_config_layers_state(
|
||||
.await?;
|
||||
layers.push(user_layer);
|
||||
|
||||
let cli_overrides_for_project_trust = if cli_overrides.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in &layers {
|
||||
merge_toml_values(&mut merged_so_far, &layer.config);
|
||||
}
|
||||
Some(build_validated_session_flags_layer(
|
||||
&trust_related_cli_overrides(cli_overrides),
|
||||
&merged_so_far,
|
||||
&session_flags_base_dir,
|
||||
"session flags",
|
||||
)?)
|
||||
};
|
||||
|
||||
if let Some(cwd) = cwd {
|
||||
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in &layers {
|
||||
merge_toml_values(&mut merged_so_far, &layer.config);
|
||||
}
|
||||
if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() {
|
||||
if let Some(cli_overrides_layer) = cli_overrides_for_project_trust.as_ref() {
|
||||
merge_toml_values(&mut merged_so_far, cli_overrides_layer);
|
||||
}
|
||||
|
||||
@@ -251,6 +257,21 @@ pub async fn load_config_layers_state(
|
||||
}
|
||||
|
||||
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
||||
let cli_overrides_layer = if cli_overrides.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in &layers {
|
||||
merge_toml_values(&mut merged_so_far, &layer.config);
|
||||
}
|
||||
Some(build_validated_session_flags_layer(
|
||||
cli_overrides,
|
||||
&merged_so_far,
|
||||
&session_flags_base_dir,
|
||||
"session flags",
|
||||
)?)
|
||||
};
|
||||
|
||||
if let Some(cli_overrides_layer) = cli_overrides_layer {
|
||||
layers.push(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
@@ -343,6 +364,128 @@ async fn load_config_toml_for_required_layer(
|
||||
Ok(create_entry(toml_value))
|
||||
}
|
||||
|
||||
fn sanitize_config_layer_entries(
|
||||
config: TomlValue,
|
||||
base_dir: &Path,
|
||||
source: impl Into<String>,
|
||||
) -> io::Result<TomlValue> {
|
||||
let source = source.into();
|
||||
let _guard = AbsolutePathBufGuard::new(base_dir);
|
||||
let sanitized = sanitize_toml_value::<ConfigToml>(config).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing {source}: {err}"),
|
||||
)
|
||||
})?;
|
||||
drop(_guard);
|
||||
|
||||
if let Some(dropped_entry) = sanitized
|
||||
.dropped_entries
|
||||
.iter()
|
||||
.find(|entry| is_invalid_security_config_entry(entry))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing security controls in {source}: {dropped_entry}"),
|
||||
));
|
||||
}
|
||||
for dropped_entry in &sanitized.dropped_entries {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
dropped_entry = %dropped_entry,
|
||||
"Ignoring invalid config entry",
|
||||
);
|
||||
}
|
||||
|
||||
resolve_relative_paths_in_config_toml(sanitized.value, base_dir)
|
||||
}
|
||||
|
||||
fn build_validated_session_flags_layer(
|
||||
cli_overrides: &[(String, TomlValue)],
|
||||
base_config: &TomlValue,
|
||||
base_dir: &Path,
|
||||
source: &str,
|
||||
) -> io::Result<TomlValue> {
|
||||
if cli_overrides.is_empty() {
|
||||
return Ok(TomlValue::Table(toml::map::Map::new()));
|
||||
}
|
||||
|
||||
let cli_overrides_layer = build_cli_overrides_layer(&cli_overrides);
|
||||
let mut candidate = base_config.clone();
|
||||
merge_toml_values(&mut candidate, &cli_overrides_layer);
|
||||
|
||||
let _guard = AbsolutePathBufGuard::new(base_dir);
|
||||
let sanitized = sanitize_toml_value::<ConfigToml>(candidate).map_err(|err| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing {source}: {err}"),
|
||||
)
|
||||
})?;
|
||||
drop(_guard);
|
||||
|
||||
let dropped_overrides: Vec<_> = cli_overrides
|
||||
.iter()
|
||||
.filter_map(|(path, _)| {
|
||||
matching_dropped_entry(path, &sanitized.dropped_entries).map(|entry| (path, entry))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some((_, dropped_entry)) = dropped_overrides
|
||||
.iter()
|
||||
.find(|(path, _)| is_invalid_security_config_entry(path))
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Error parsing security controls in {source}: {dropped_entry}"),
|
||||
));
|
||||
}
|
||||
|
||||
for (path, dropped_entry) in &dropped_overrides {
|
||||
tracing::warn!(
|
||||
source = %source,
|
||||
override_path = %path,
|
||||
dropped_entry = %dropped_entry,
|
||||
"Ignoring invalid config entry",
|
||||
);
|
||||
}
|
||||
|
||||
let kept_overrides: Vec<_> = cli_overrides
|
||||
.into_iter()
|
||||
.filter(|(path, _)| matching_dropped_entry(path, &sanitized.dropped_entries).is_none())
|
||||
.cloned()
|
||||
.collect();
|
||||
resolve_relative_paths_in_config_toml(build_cli_overrides_layer(&kept_overrides), base_dir)
|
||||
}
|
||||
|
||||
fn trust_related_cli_overrides(cli_overrides: &[(String, TomlValue)]) -> Vec<(String, TomlValue)> {
|
||||
cli_overrides
|
||||
.iter()
|
||||
.filter(|(path, _)| path == "project_root_markers" || path.starts_with("projects."))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matching_dropped_entry<'a>(
|
||||
override_path: &str,
|
||||
dropped_entries: &'a [String],
|
||||
) -> Option<&'a str> {
|
||||
dropped_entries
|
||||
.iter()
|
||||
.find(|entry| config_paths_overlap(override_path, config_entry_path(entry)))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
fn config_paths_overlap(path: &str, other: &str) -> bool {
|
||||
config_path_contains(path, other) || config_path_contains(other, path)
|
||||
}
|
||||
|
||||
fn config_path_contains(path: &str, prefix: &str) -> bool {
|
||||
path == prefix
|
||||
|| path
|
||||
.strip_prefix(prefix)
|
||||
.is_some_and(|rest| rest.starts_with('.') || rest.starts_with('['))
|
||||
}
|
||||
|
||||
/// If available, apply requirements from the platform system
|
||||
/// `requirements.toml` location to `config_requirements_toml` by filling in
|
||||
/// any unset fields.
|
||||
@@ -736,6 +879,26 @@ pub(crate) fn resolve_relative_paths_in_config_toml(
|
||||
))
|
||||
}
|
||||
|
||||
fn is_invalid_security_config_entry(dropped_entry: &str) -> bool {
|
||||
matches!(
|
||||
config_entry_leaf_key(dropped_entry),
|
||||
"approval_policy" | "sandbox_mode"
|
||||
)
|
||||
}
|
||||
|
||||
fn config_entry_leaf_key(dropped_entry: &str) -> &str {
|
||||
let path = config_entry_path(dropped_entry);
|
||||
let leaf = path.rsplit('.').next().unwrap_or(path);
|
||||
leaf.split('[').next().unwrap_or(leaf)
|
||||
}
|
||||
|
||||
fn config_entry_path(dropped_entry: &str) -> &str {
|
||||
dropped_entry
|
||||
.split_once(':')
|
||||
.map_or(dropped_entry, |(path, _)| path)
|
||||
.trim()
|
||||
}
|
||||
|
||||
/// Ensure that every field in `original` is present in the returned
|
||||
/// `toml::Value`, taking the value from `resolved` where possible. This ensures
|
||||
/// the fields that we "removed" during the serialize/deserialize round-trip in
|
||||
@@ -858,8 +1021,24 @@ async fn load_project_layers(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
||||
let source = format!("project config file {}", config_file.as_path().display());
|
||||
let config = if decision.is_trusted() {
|
||||
sanitize_config_layer_entries(config, dot_codex_abs.as_path(), source)?
|
||||
} else {
|
||||
match sanitize_config_layer_entries(config, dot_codex_abs.as_path(), source) {
|
||||
Ok(config) => config,
|
||||
Err(_) => {
|
||||
layers.push(project_layer_entry(
|
||||
trust_context,
|
||||
&dot_codex_abs,
|
||||
&layer_dir,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
/*config_toml_exists*/ true,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let entry = project_layer_entry(
|
||||
trust_context,
|
||||
&dot_codex_abs,
|
||||
|
||||
@@ -380,6 +380,301 @@ flag = false
|
||||
assert!(raw.contains("value = \"managed\""));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?;
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
assert_eq!(
|
||||
writable_roots
|
||||
.iter()
|
||||
.filter(|root| **root == expected_root)
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
other => panic!("expected workspace-write policy, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_ignore_invalid_config_entry_without_dropping_valid_entries()
|
||||
-> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
model = "managed"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["relative/path"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.model.as_deref(), Some("managed"));
|
||||
let effective_config = config.config_layer_stack.effective_config();
|
||||
let writable_roots = effective_config
|
||||
.get("sandbox_workspace_write")
|
||||
.and_then(|value| value.get("writable_roots"))
|
||||
.and_then(TomlValue::as_array);
|
||||
assert_eq!(writable_roots, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_invalid_security_config_entry_fails_closed() -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
for (payload, expected_fragment) in [
|
||||
(
|
||||
"approval_policy = \"bogus\"\nmodel = \"managed\"\n",
|
||||
"approval_policy",
|
||||
),
|
||||
(
|
||||
"sandbox_mode = \"bogus\"\nmodel = \"managed\"\n",
|
||||
"sandbox_mode",
|
||||
),
|
||||
(
|
||||
"[profiles.team]\napproval_policy = \"bogus\"\n",
|
||||
"profiles.team.approval_policy",
|
||||
),
|
||||
(
|
||||
"[profiles.team]\nsandbox_mode = \"bogus\"\n",
|
||||
"profiles.team.sandbox_mode",
|
||||
),
|
||||
] {
|
||||
let tmp = tempdir()?;
|
||||
let err = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(payload.as_bytes()),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid managed security config should fail closed");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("Error parsing managed config from MDM"));
|
||||
assert!(message.contains(expected_fragment), "{message}");
|
||||
assert!(message.contains("bogus"), "{message}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_ignore_invalid_payload() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some("%%%".to_string()),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.model.as_deref(), Some("user"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_invalid_security_requirements_fail_closed() -> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
for (payload, expected_fragment) in [
|
||||
(
|
||||
"allowed_approval_policies = [\"bogus\"]",
|
||||
"allowed_approval_policies",
|
||||
),
|
||||
(
|
||||
"allowed_sandbox_modes = [\"bogus\"]",
|
||||
"allowed_sandbox_modes",
|
||||
),
|
||||
(
|
||||
"allowed_web_search_modes = [\"bogus\"]",
|
||||
"allowed_web_search_modes",
|
||||
),
|
||||
] {
|
||||
let tmp = tempdir()?;
|
||||
let err = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(payload.as_bytes()),
|
||||
),
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid managed security requirements should fail closed");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("Error parsing managed security controls from MDM"));
|
||||
assert!(message.contains(expected_fragment), "{message}");
|
||||
assert!(message.contains("bogus"), "{message}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_ignore_invalid_non_security_requirements_entry() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
allowed_sandbox_modes = ["read-only"]
|
||||
|
||||
[features]
|
||||
personality = "bogus"
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
state.requirements_toml().allowed_sandbox_modes,
|
||||
Some(vec![crate::config_loader::SandboxModeRequirement::ReadOnly])
|
||||
);
|
||||
assert_eq!(state.requirements_toml().feature_requirements, None);
|
||||
assert_eq!(
|
||||
*state.requirements().sandbox_policy.get(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_ignore_invalid_non_security_requirements_payload_without_security_entries()
|
||||
-> anyhow::Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let state = load_config_layers_state(
|
||||
tmp.path(),
|
||||
Some(AbsolutePathBuf::try_from(tmp.path())?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(String::new()),
|
||||
macos_managed_config_requirements_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
[features]
|
||||
personality = "bogus"
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
state.requirements_toml(),
|
||||
&ConfigRequirementsToml::default()
|
||||
);
|
||||
assert_eq!(
|
||||
state.requirements().approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
assert_eq!(
|
||||
*state.requirements().sandbox_policy.get(),
|
||||
SandboxPolicy::new_read_only_policy()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
|
||||
@@ -906,6 +1201,128 @@ async fn cli_override_model_instructions_file_sets_base_instructions() -> std::i
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_flags_ignore_invalid_non_security_entries() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
let cli_overrides = vec![
|
||||
(
|
||||
"model".to_string(),
|
||||
TomlValue::String("override".to_string()),
|
||||
),
|
||||
(
|
||||
"model_context_window".to_string(),
|
||||
TomlValue::String("bogus".to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_layer = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
true,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|layer| layer.name == super::ConfigLayerSource::SessionFlags)
|
||||
.expect("session flags layer");
|
||||
assert_eq!(
|
||||
session_layer.config.get("model"),
|
||||
Some(&TomlValue::String("override".to_string()))
|
||||
);
|
||||
assert_eq!(session_layer.config.get("model_context_window"), None);
|
||||
assert_eq!(
|
||||
layers.effective_config().get("model"),
|
||||
Some(&TomlValue::String("override".to_string()))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_flags_invalid_security_entries_fail_closed() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
|
||||
|
||||
for (cli_overrides, expected_fragment) in [
|
||||
(
|
||||
vec![(
|
||||
"approval_policy".to_string(),
|
||||
TomlValue::String("bogus".to_string()),
|
||||
)],
|
||||
"approval_policy",
|
||||
),
|
||||
(
|
||||
vec![(
|
||||
"profiles.team.sandbox_mode".to_string(),
|
||||
TomlValue::String("bogus".to_string()),
|
||||
)],
|
||||
"profiles.team.sandbox_mode",
|
||||
),
|
||||
] {
|
||||
let err = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd.clone()),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid session security config should fail closed");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("Error parsing security controls in session flags"));
|
||||
assert!(message.contains(expected_fragment), "{message}");
|
||||
assert!(message.contains("bogus"), "{message}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_flags_ignore_invalid_project_root_markers_entry() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(nested.join(".codex")).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
nested.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"model = \"project\"\n",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.cli_overrides(vec![(
|
||||
"project_root_markers".to_string(),
|
||||
TomlValue::String("bogus".to_string()),
|
||||
)])
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.model.as_deref(), Some("project"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
@@ -949,6 +1366,95 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_project_config_ignores_invalid_non_security_entries() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(nested.join(".codex")).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
nested.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"model = \"project\"\nmodel_context_window = \"bogus\"\n",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layer = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
true,
|
||||
)
|
||||
.into_iter()
|
||||
.find(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.expect("project layer");
|
||||
assert_eq!(
|
||||
project_layer.config.get("model"),
|
||||
Some(&TomlValue::String("project".to_string()))
|
||||
);
|
||||
assert_eq!(project_layer.config.get("model_context_window"), None);
|
||||
assert_eq!(
|
||||
layers.effective_config().get("model"),
|
||||
Some(&TomlValue::String("project".to_string()))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_project_config_invalid_security_entries_fail_closed() -> anyhow::Result<()> {
|
||||
for (payload, expected_fragment) in [
|
||||
("approval_policy = \"bogus\"\n", "approval_policy"),
|
||||
(
|
||||
"[profiles.team]\nsandbox_mode = \"bogus\"\n",
|
||||
"profiles.team.sandbox_mode",
|
||||
),
|
||||
] {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(nested.join(".codex")).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), payload).await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(&codex_home, &project_root, TrustLevel::Trusted, None).await?;
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
|
||||
let err = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid project security config should fail closed");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("Error parsing security controls in project config file"));
|
||||
assert!(message.contains(expected_fragment), "{message}");
|
||||
assert!(message.contains("bogus"), "{message}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_home_is_not_loaded_as_project_layer_from_home_dir() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -179,12 +179,16 @@ impl<'de> Deserialize<'de> for AbsolutePathBuf {
|
||||
Some(base) => {
|
||||
Ok(Self::resolve_path_against_base(path, base).map_err(SerdeError::custom)?)
|
||||
}
|
||||
None if path.is_absolute() => {
|
||||
Self::from_absolute_path(path).map_err(SerdeError::custom)
|
||||
None => {
|
||||
let expanded = Self::maybe_expand_home_directory(&path);
|
||||
if expanded.is_absolute() {
|
||||
Self::from_absolute_path(expanded).map_err(SerdeError::custom)
|
||||
} else {
|
||||
Err(SerdeError::custom(
|
||||
"AbsolutePathBuf deserialized without a base path",
|
||||
))
|
||||
}
|
||||
}
|
||||
None => Err(SerdeError::custom(
|
||||
"AbsolutePathBuf deserialized without a base path",
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -246,6 +250,17 @@ mod tests {
|
||||
assert_eq!(abs_path_buf.as_path(), home.as_path());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_root_on_non_windows_is_expanded_without_base_path() {
|
||||
let Some(home) = home_dir() else {
|
||||
return;
|
||||
};
|
||||
let abs_path_buf =
|
||||
serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize");
|
||||
assert_eq!(abs_path_buf.as_path(), home.as_path());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_subpath_on_non_windows_is_expanded_in_deserialization() {
|
||||
@@ -260,6 +275,17 @@ mod tests {
|
||||
assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_subpath_on_non_windows_is_expanded_without_base_path() {
|
||||
let Some(home) = home_dir() else {
|
||||
return;
|
||||
};
|
||||
let abs_path_buf =
|
||||
serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize");
|
||||
assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn home_directory_double_slash_on_non_windows_is_expanded_in_deserialization() {
|
||||
@@ -288,4 +314,15 @@ mod tests {
|
||||
base_dir.join("~").join("code").as_path()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_without_base_path_fails_in_deserialization() {
|
||||
let err = serde_json::from_str::<AbsolutePathBuf>("\"subdir/file.txt\"")
|
||||
.expect_err("relative path without a base path should fail");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("AbsolutePathBuf deserialized without a base path"),
|
||||
"unexpected error: {err}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user