mirror of
https://github.com/openai/codex.git
synced 2026-04-25 23:24:55 +00:00
Extract config-layer IO and managed requirements loading into codex-config so codex-core keeps a thinner config loader facade. Co-authored-by: Codex <noreply@openai.com>
831 lines
30 KiB
Rust
831 lines
30 KiB
Rust
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use crate::config::ConfigToml;
|
|
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_protocol::config_types::TrustLevel;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
|
use dunce::canonicalize as normalize_path;
|
|
use serde::Deserialize;
|
|
use std::io;
|
|
use std::path::Path;
|
|
use toml::Value as TomlValue;
|
|
|
|
pub use codex_config::AppRequirementToml;
|
|
pub use codex_config::AppsRequirementsToml;
|
|
pub use codex_config::CloudRequirementsLoadError;
|
|
pub use codex_config::CloudRequirementsLoadErrorCode;
|
|
pub use codex_config::CloudRequirementsLoader;
|
|
pub use codex_config::ConfigError;
|
|
pub use codex_config::ConfigLayerEntry;
|
|
pub use codex_config::ConfigLayerStack;
|
|
pub use codex_config::ConfigLayerStackOrdering;
|
|
pub use codex_config::ConfigLoadError;
|
|
pub use codex_config::ConfigRequirements;
|
|
pub use codex_config::ConfigRequirementsToml;
|
|
pub use codex_config::ConstrainedWithSource;
|
|
pub use codex_config::FeatureRequirementsToml;
|
|
use codex_config::LoadedConfigLayers;
|
|
pub use codex_config::LoaderOverrides;
|
|
pub use codex_config::McpServerIdentity;
|
|
pub use codex_config::McpServerRequirement;
|
|
pub use codex_config::NetworkConstraints;
|
|
pub use codex_config::NetworkRequirementsToml;
|
|
pub use codex_config::RequirementSource;
|
|
pub use codex_config::ResidencyRequirement;
|
|
pub use codex_config::SandboxModeRequirement;
|
|
pub use codex_config::Sourced;
|
|
pub use codex_config::TextPosition;
|
|
pub use codex_config::TextRange;
|
|
pub use codex_config::WebSearchModeRequirement;
|
|
pub(crate) use codex_config::build_cli_overrides_layer;
|
|
pub(crate) use codex_config::config_error_from_toml;
|
|
pub use codex_config::format_config_error;
|
|
pub use codex_config::format_config_error_with_source;
|
|
pub(crate) use codex_config::io_error_from_config_error;
|
|
use codex_config::load_config_layers_internal;
|
|
use codex_config::load_managed_admin_requirements;
|
|
use codex_config::load_requirements_from_legacy_scheme;
|
|
pub(crate) use codex_config::load_requirements_toml;
|
|
pub use codex_config::merge_toml_values;
|
|
use codex_config::system_config_toml_file;
|
|
use codex_config::system_requirements_toml_file;
|
|
#[cfg(test)]
|
|
pub(crate) use codex_config::version_for_toml;
|
|
|
|
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
|
|
|
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
|
|
codex_config::first_layer_config_error::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
|
}
|
|
|
|
pub(crate) async fn first_layer_config_error_from_entries(
|
|
layers: &[ConfigLayerEntry],
|
|
) -> Option<ConfigError> {
|
|
codex_config::first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE)
|
|
.await
|
|
}
|
|
|
|
/// To build up the set of admin-enforced constraints, we build up from multiple
|
|
/// configuration layers in the following order, but a constraint defined in an
|
|
/// earlier layer cannot be overridden by a later layer:
|
|
///
|
|
/// - cloud: managed cloud requirements
|
|
/// - admin: managed preferences (*)
|
|
/// - system `/etc/codex/requirements.toml` (Unix) or
|
|
/// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows)
|
|
///
|
|
/// For backwards compatibility, we also load from
|
|
/// `managed_config.toml` and map it to `requirements.toml`.
|
|
///
|
|
/// Configuration is built up from multiple layers in the following order:
|
|
///
|
|
/// - admin: managed preferences (*)
|
|
/// - system `/etc/codex/config.toml` (Unix) or
|
|
/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
|
|
/// - user `${CODEX_HOME}/config.toml`
|
|
/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted)
|
|
/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted)
|
|
/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted)
|
|
/// - runtime e.g., --config flags, model selector in UI
|
|
///
|
|
/// (*) Only available on macOS via managed device profiles.
|
|
///
|
|
/// See https://developers.openai.com/codex/security for details.
|
|
///
|
|
/// When loading the config stack for a thread, there should be a `cwd`
|
|
/// associated with it such that `cwd` should be `Some(...)`. Only for
|
|
/// thread-agnostic config loading (e.g., for the app server's `/config`
|
|
/// endpoint) should `cwd` be `None`.
|
|
pub async fn load_config_layers_state(
|
|
codex_home: &Path,
|
|
cwd: Option<AbsolutePathBuf>,
|
|
cli_overrides: &[(String, TomlValue)],
|
|
overrides: LoaderOverrides,
|
|
cloud_requirements: CloudRequirementsLoader,
|
|
) -> io::Result<ConfigLayerStack> {
|
|
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
|
|
|
|
if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? {
|
|
config_requirements_toml
|
|
.merge_unset_fields(RequirementSource::CloudRequirements, requirements);
|
|
}
|
|
|
|
load_managed_admin_requirements(
|
|
&mut config_requirements_toml,
|
|
overrides
|
|
.macos_managed_config_requirements_base64
|
|
.as_deref(),
|
|
)
|
|
.await?;
|
|
|
|
// Honor the system requirements.toml location.
|
|
let requirements_toml_file = system_requirements_toml_file()?;
|
|
load_requirements_toml(&mut config_requirements_toml, requirements_toml_file).await?;
|
|
|
|
// Make a best-effort to support the legacy `managed_config.toml` as a
|
|
// requirements specification.
|
|
let loaded_config_layers = load_config_layers_internal(codex_home, overrides).await?;
|
|
load_requirements_from_legacy_scheme(
|
|
&mut config_requirements_toml,
|
|
loaded_config_layers.clone(),
|
|
)
|
|
.await?;
|
|
|
|
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,
|
|
)?)
|
|
};
|
|
|
|
// Include an entry for the "system" config folder, loading its config.toml,
|
|
// if it exists.
|
|
let system_config_toml_file = system_config_toml_file()?;
|
|
let system_layer =
|
|
load_config_toml_for_required_layer(&system_config_toml_file, |config_toml| {
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::System {
|
|
file: system_config_toml_file.clone(),
|
|
},
|
|
config_toml,
|
|
)
|
|
})
|
|
.await?;
|
|
layers.push(system_layer);
|
|
|
|
// Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file
|
|
// exists, but is malformed, then this error should be propagated to the
|
|
// user.
|
|
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home)?;
|
|
let user_layer = load_config_toml_for_required_layer(&user_file, |config_toml| {
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: user_file.clone(),
|
|
},
|
|
config_toml,
|
|
)
|
|
})
|
|
.await?;
|
|
layers.push(user_layer);
|
|
|
|
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() {
|
|
merge_toml_values(&mut merged_so_far, cli_overrides_layer);
|
|
}
|
|
|
|
let project_root_markers = match project_root_markers_from_config(&merged_so_far) {
|
|
Ok(markers) => markers.unwrap_or_else(default_project_root_markers),
|
|
Err(err) => {
|
|
if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
|
|
return Err(io_error_from_config_error(
|
|
io::ErrorKind::InvalidData,
|
|
config_error,
|
|
/*source*/ None,
|
|
));
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
let project_trust_context = match project_trust_context(
|
|
&merged_so_far,
|
|
&cwd,
|
|
&project_root_markers,
|
|
codex_home,
|
|
&user_file,
|
|
)
|
|
.await
|
|
{
|
|
Ok(context) => context,
|
|
Err(err) => {
|
|
let source = err
|
|
.get_ref()
|
|
.and_then(|err| err.downcast_ref::<toml::de::Error>())
|
|
.cloned();
|
|
if let Some(config_error) = first_layer_config_error_from_entries(&layers).await {
|
|
return Err(io_error_from_config_error(
|
|
io::ErrorKind::InvalidData,
|
|
config_error,
|
|
source,
|
|
));
|
|
}
|
|
return Err(err);
|
|
}
|
|
};
|
|
let project_layers = load_project_layers(
|
|
&cwd,
|
|
&project_trust_context.project_root,
|
|
&project_trust_context,
|
|
codex_home,
|
|
)
|
|
.await?;
|
|
layers.extend(project_layers);
|
|
}
|
|
|
|
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
|
if let Some(cli_overrides_layer) = cli_overrides_layer {
|
|
layers.push(ConfigLayerEntry::new(
|
|
ConfigLayerSource::SessionFlags,
|
|
cli_overrides_layer,
|
|
));
|
|
}
|
|
|
|
// Make a best-effort to support the legacy `managed_config.toml` as a
|
|
// config layer on top of everything else. For fields in
|
|
// `managed_config.toml` that do not have an equivalent in
|
|
// `ConfigRequirements`, note users can still override these values on a
|
|
// per-turn basis in the TUI and VS Code.
|
|
let LoadedConfigLayers {
|
|
managed_config,
|
|
managed_config_from_mdm,
|
|
} = loaded_config_layers;
|
|
if let Some(config) = managed_config {
|
|
let managed_parent = config.file.as_path().parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Managed config file {} has no parent directory",
|
|
config.file.as_path().display()
|
|
),
|
|
)
|
|
})?;
|
|
let managed_config =
|
|
resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?;
|
|
layers.push(ConfigLayerEntry::new(
|
|
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
|
|
managed_config,
|
|
));
|
|
}
|
|
if let Some(config) = managed_config_from_mdm {
|
|
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
|
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
|
config.managed_config,
|
|
config.raw_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`.
|
|
/// - If the file exists and is valid TOML, passes the parsed `toml::Value` to
|
|
/// `create_entry` and returns the resulting layer entry.
|
|
/// - If the file does not exist, uses an empty `Table` with `create_entry` and
|
|
/// returns the resulting layer entry.
|
|
/// - If there is an error reading the file or parsing the TOML, returns an
|
|
/// error.
|
|
async fn load_config_toml_for_required_layer(
|
|
config_toml: impl AsRef<Path>,
|
|
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
|
|
) -> io::Result<ConfigLayerEntry> {
|
|
let toml_file = config_toml.as_ref();
|
|
let toml_value = match tokio::fs::read_to_string(toml_file).await {
|
|
Ok(contents) => {
|
|
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
|
|
let config_error = config_error_from_toml(toml_file, &contents, err.clone());
|
|
io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err))
|
|
})?;
|
|
let config_parent = toml_file.parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Config file {} has no parent directory",
|
|
toml_file.display()
|
|
),
|
|
)
|
|
})?;
|
|
resolve_relative_paths_in_config_toml(config, config_parent)
|
|
}
|
|
Err(e) => {
|
|
if e.kind() == io::ErrorKind::NotFound {
|
|
Ok(TomlValue::Table(toml::map::Map::new()))
|
|
} else {
|
|
Err(io::Error::new(
|
|
e.kind(),
|
|
format!("Failed to read config file {}: {e}", toml_file.display()),
|
|
))
|
|
}
|
|
}
|
|
}?;
|
|
|
|
Ok(create_entry(toml_value))
|
|
}
|
|
|
|
/// Reads `project_root_markers` from the [toml::Value] produced by merging
|
|
/// `config.toml` from the config layers in the stack preceding
|
|
/// [ConfigLayerSource::Project].
|
|
///
|
|
/// Invariants:
|
|
/// - If `project_root_markers` is not specified, returns `Ok(None)`.
|
|
/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
|
|
/// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
|
|
/// empty array, which indicates that root detection should be disabled).
|
|
/// - Returns an error if `project_root_markers` is specified but is not an
|
|
/// array of strings.
|
|
pub(crate) fn project_root_markers_from_config(
|
|
config: &TomlValue,
|
|
) -> io::Result<Option<Vec<String>>> {
|
|
let Some(table) = config.as_table() else {
|
|
return Ok(None);
|
|
};
|
|
let Some(markers_value) = table.get("project_root_markers") else {
|
|
return Ok(None);
|
|
};
|
|
let TomlValue::Array(entries) = markers_value else {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"project_root_markers must be an array of strings",
|
|
));
|
|
};
|
|
if entries.is_empty() {
|
|
return Ok(Some(Vec::new()));
|
|
}
|
|
let mut markers = Vec::new();
|
|
for entry in entries {
|
|
let Some(marker) = entry.as_str() else {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"project_root_markers must be an array of strings",
|
|
));
|
|
};
|
|
markers.push(marker.to_string());
|
|
}
|
|
Ok(Some(markers))
|
|
}
|
|
|
|
pub(crate) fn default_project_root_markers() -> Vec<String> {
|
|
DEFAULT_PROJECT_ROOT_MARKERS
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect()
|
|
}
|
|
|
|
struct ProjectTrustContext {
|
|
project_root: AbsolutePathBuf,
|
|
project_root_key: String,
|
|
repo_root_key: Option<String>,
|
|
projects_trust: std::collections::HashMap<String, TrustLevel>,
|
|
user_config_file: AbsolutePathBuf,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ProjectTrustConfigToml {
|
|
projects: Option<std::collections::HashMap<String, crate::config::ProjectConfig>>,
|
|
}
|
|
|
|
struct ProjectTrustDecision {
|
|
trust_level: Option<TrustLevel>,
|
|
trust_key: String,
|
|
}
|
|
|
|
impl ProjectTrustDecision {
|
|
fn is_trusted(&self) -> bool {
|
|
matches!(self.trust_level, Some(TrustLevel::Trusted))
|
|
}
|
|
}
|
|
|
|
impl ProjectTrustContext {
|
|
fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision {
|
|
let dir_key = dir.as_path().to_string_lossy().to_string();
|
|
if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() {
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key: dir_key,
|
|
};
|
|
}
|
|
|
|
if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() {
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key: self.project_root_key.clone(),
|
|
};
|
|
}
|
|
|
|
if let Some(repo_root_key) = self.repo_root_key.as_ref()
|
|
&& let Some(trust_level) = self.projects_trust.get(repo_root_key).copied()
|
|
{
|
|
return ProjectTrustDecision {
|
|
trust_level: Some(trust_level),
|
|
trust_key: repo_root_key.clone(),
|
|
};
|
|
}
|
|
|
|
ProjectTrustDecision {
|
|
trust_level: None,
|
|
trust_key: self
|
|
.repo_root_key
|
|
.clone()
|
|
.unwrap_or_else(|| self.project_root_key.clone()),
|
|
}
|
|
}
|
|
|
|
fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option<String> {
|
|
let decision = self.decision_for_dir(dir);
|
|
if decision.is_trusted() {
|
|
return None;
|
|
}
|
|
|
|
let trust_key = decision.trust_key.as_str();
|
|
let user_config_file = self.user_config_file.as_path().display();
|
|
match decision.trust_level {
|
|
Some(TrustLevel::Untrusted) => Some(format!(
|
|
"{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted."
|
|
)),
|
|
_ => Some(format!(
|
|
"To load config.toml, add {trust_key} as a trusted project in {user_config_file}."
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn project_layer_entry(
|
|
trust_context: &ProjectTrustContext,
|
|
dot_codex_folder: &AbsolutePathBuf,
|
|
layer_dir: &AbsolutePathBuf,
|
|
config: TomlValue,
|
|
config_toml_exists: bool,
|
|
) -> ConfigLayerEntry {
|
|
let source = ConfigLayerSource::Project {
|
|
dot_codex_folder: dot_codex_folder.clone(),
|
|
};
|
|
|
|
if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) {
|
|
ConfigLayerEntry::new_disabled(source, config, reason)
|
|
} else {
|
|
ConfigLayerEntry::new(source, config)
|
|
}
|
|
}
|
|
|
|
async fn project_trust_context(
|
|
merged_config: &TomlValue,
|
|
cwd: &AbsolutePathBuf,
|
|
project_root_markers: &[String],
|
|
config_base_dir: &Path,
|
|
user_config_file: &AbsolutePathBuf,
|
|
) -> io::Result<ProjectTrustContext> {
|
|
let project_trust_config: ProjectTrustConfigToml = {
|
|
let _guard = AbsolutePathBufGuard::new(config_base_dir);
|
|
merged_config
|
|
.clone()
|
|
.try_into()
|
|
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?
|
|
};
|
|
|
|
let project_root = find_project_root(cwd, project_root_markers).await?;
|
|
let projects = project_trust_config.projects.unwrap_or_default();
|
|
|
|
let project_root_key = project_root.as_path().to_string_lossy().to_string();
|
|
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
|
|
let repo_root_key = repo_root
|
|
.as_ref()
|
|
.map(|root| root.to_string_lossy().to_string());
|
|
|
|
let projects_trust = projects
|
|
.into_iter()
|
|
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
|
|
.collect();
|
|
|
|
Ok(ProjectTrustContext {
|
|
project_root,
|
|
project_root_key,
|
|
repo_root_key,
|
|
projects_trust,
|
|
user_config_file: user_config_file.clone(),
|
|
})
|
|
}
|
|
|
|
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
|
|
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
|
|
/// `toml::Value` with the same shape but with paths resolved.
|
|
///
|
|
/// This ensures that multiple config layers can be merged together correctly
|
|
/// even if they were loaded from different directories.
|
|
pub(crate) fn resolve_relative_paths_in_config_toml(
|
|
value_from_config_toml: TomlValue,
|
|
base_dir: &Path,
|
|
) -> io::Result<TomlValue> {
|
|
// Use the serialize/deserialize round-trip to convert the
|
|
// `toml::Value` into a `ConfigToml` with `AbsolutePath
|
|
let _guard = AbsolutePathBufGuard::new(base_dir);
|
|
let Ok(resolved) = value_from_config_toml.clone().try_into::<ConfigToml>() else {
|
|
return Ok(value_from_config_toml);
|
|
};
|
|
drop(_guard);
|
|
|
|
let resolved_value = TomlValue::try_from(resolved).map_err(|e| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("Failed to serialize resolved config: {e}"),
|
|
)
|
|
})?;
|
|
|
|
Ok(copy_shape_from_original(
|
|
&value_from_config_toml,
|
|
&resolved_value,
|
|
))
|
|
}
|
|
|
|
/// 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
|
|
/// `resolve_config_paths` are preserved, out of an abundance of caution.
|
|
fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue {
|
|
match (original, resolved) {
|
|
(TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => {
|
|
let mut table = toml::map::Map::new();
|
|
for (key, original_value) in original_table {
|
|
let resolved_value = resolved_table.get(key).unwrap_or(original_value);
|
|
table.insert(
|
|
key.clone(),
|
|
copy_shape_from_original(original_value, resolved_value),
|
|
);
|
|
}
|
|
TomlValue::Table(table)
|
|
}
|
|
(TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => {
|
|
let mut items = Vec::new();
|
|
for (index, original_value) in original_array.iter().enumerate() {
|
|
let resolved_value = resolved_array.get(index).unwrap_or(original_value);
|
|
items.push(copy_shape_from_original(original_value, resolved_value));
|
|
}
|
|
TomlValue::Array(items)
|
|
}
|
|
(_, resolved_value) => resolved_value.clone(),
|
|
}
|
|
}
|
|
|
|
async fn find_project_root(
|
|
cwd: &AbsolutePathBuf,
|
|
project_root_markers: &[String],
|
|
) -> io::Result<AbsolutePathBuf> {
|
|
if project_root_markers.is_empty() {
|
|
return Ok(cwd.clone());
|
|
}
|
|
|
|
for ancestor in cwd.as_path().ancestors() {
|
|
for marker in project_root_markers {
|
|
let marker_path = ancestor.join(marker);
|
|
if tokio::fs::metadata(&marker_path).await.is_ok() {
|
|
return AbsolutePathBuf::from_absolute_path(ancestor);
|
|
}
|
|
}
|
|
}
|
|
Ok(cwd.clone())
|
|
}
|
|
|
|
/// Return the appropriate list of layers (each with
|
|
/// [ConfigLayerSource::Project] as the source) between `cwd` and
|
|
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
|
|
/// starting from folders closest to `project_root` (which is the lowest
|
|
/// precedence) to those closest to `cwd` (which is the highest precedence).
|
|
async fn load_project_layers(
|
|
cwd: &AbsolutePathBuf,
|
|
project_root: &AbsolutePathBuf,
|
|
trust_context: &ProjectTrustContext,
|
|
codex_home: &Path,
|
|
) -> io::Result<Vec<ConfigLayerEntry>> {
|
|
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
|
let codex_home_normalized =
|
|
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
|
|
let mut dirs = cwd
|
|
.as_path()
|
|
.ancestors()
|
|
.scan(false, |done, a| {
|
|
if *done {
|
|
None
|
|
} else {
|
|
if a == project_root.as_path() {
|
|
*done = true;
|
|
}
|
|
Some(a)
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
dirs.reverse();
|
|
|
|
let mut layers = Vec::new();
|
|
for dir in dirs {
|
|
let dot_codex = dir.join(".codex");
|
|
if !tokio::fs::metadata(&dot_codex)
|
|
.await
|
|
.map(|meta| meta.is_dir())
|
|
.unwrap_or(false)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?;
|
|
let decision = trust_context.decision_for_dir(&layer_dir);
|
|
let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
|
|
let dot_codex_normalized =
|
|
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
|
|
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
|
|
continue;
|
|
}
|
|
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?;
|
|
match tokio::fs::read_to_string(&config_file).await {
|
|
Ok(contents) => {
|
|
let config: TomlValue = match toml::from_str(&contents) {
|
|
Ok(config) => config,
|
|
Err(e) => {
|
|
if decision.is_trusted() {
|
|
let config_file_display = config_file.as_path().display();
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!(
|
|
"Error parsing project config file {config_file_display}: {e}"
|
|
),
|
|
));
|
|
}
|
|
layers.push(project_layer_entry(
|
|
trust_context,
|
|
&dot_codex_abs,
|
|
&layer_dir,
|
|
TomlValue::Table(toml::map::Map::new()),
|
|
/*config_toml_exists*/ true,
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
let config =
|
|
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
|
let entry = project_layer_entry(
|
|
trust_context,
|
|
&dot_codex_abs,
|
|
&layer_dir,
|
|
config,
|
|
/*config_toml_exists*/ true,
|
|
);
|
|
layers.push(entry);
|
|
}
|
|
Err(err) => {
|
|
if err.kind() == io::ErrorKind::NotFound {
|
|
// If there is no config.toml file, record an empty entry
|
|
// for this project layer, as this may still have subfolders
|
|
// that are significant in the overall ConfigLayerStack.
|
|
layers.push(project_layer_entry(
|
|
trust_context,
|
|
&dot_codex_abs,
|
|
&layer_dir,
|
|
TomlValue::Table(toml::map::Map::new()),
|
|
/*config_toml_exists*/ false,
|
|
));
|
|
} else {
|
|
let config_file_display = config_file.as_path().display();
|
|
return Err(io::Error::new(
|
|
err.kind(),
|
|
format!("Failed to read project config file {config_file_display}: {err}"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(layers)
|
|
}
|
|
|
|
// Cannot name this `mod tests` because of tests.rs in this folder.
|
|
#[cfg(test)]
|
|
mod unit_tests {
|
|
use super::*;
|
|
use codex_config::ManagedConfigFromFile;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> {
|
|
let tmp = tempdir()?;
|
|
let base_dir = tmp.path();
|
|
let contents = r#"
|
|
# This is a field recognized by config.toml that is an AbsolutePathBuf in
|
|
# the ConfigToml struct.
|
|
model_instructions_file = "./some_file.md"
|
|
|
|
# This is a field recognized by config.toml.
|
|
model = "gpt-1000"
|
|
|
|
# This is a field not recognized by config.toml.
|
|
foo = "xyzzy"
|
|
"#;
|
|
let user_config: TomlValue = toml::from_str(contents)?;
|
|
|
|
let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?;
|
|
let mut expected_toml_value = toml::map::Map::new();
|
|
expected_toml_value.insert(
|
|
"model_instructions_file".to_string(),
|
|
TomlValue::String(
|
|
AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir)?
|
|
.as_path()
|
|
.to_string_lossy()
|
|
.to_string(),
|
|
),
|
|
);
|
|
expected_toml_value.insert(
|
|
"model".to_string(),
|
|
TomlValue::String("gpt-1000".to_string()),
|
|
);
|
|
expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string()));
|
|
assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let managed_path = AbsolutePathBuf::try_from(tmp.path().join("managed_config.toml"))
|
|
.expect("managed path");
|
|
let loaded_layers = LoadedConfigLayers {
|
|
managed_config: Some(ManagedConfigFromFile {
|
|
managed_config: toml::toml! {
|
|
sandbox_mode = "workspace-write"
|
|
}
|
|
.into(),
|
|
file: managed_path.clone(),
|
|
}),
|
|
managed_config_from_mdm: None,
|
|
};
|
|
let mut requirements_with_sources = ConfigRequirementsWithSources::default();
|
|
load_requirements_from_legacy_scheme(&mut requirements_with_sources, loaded_layers)
|
|
.await
|
|
.expect("load legacy requirements");
|
|
let requirements: ConfigRequirements = requirements_with_sources
|
|
.try_into()
|
|
.expect("requirements parse");
|
|
|
|
assert_eq!(
|
|
requirements.sandbox_policy.get(),
|
|
&SandboxPolicy::new_read_only_policy()
|
|
);
|
|
assert!(
|
|
requirements
|
|
.sandbox_policy
|
|
.can_set(&SandboxPolicy::new_workspace_write_policy())
|
|
.is_ok()
|
|
);
|
|
assert_eq!(
|
|
requirements
|
|
.sandbox_policy
|
|
.can_set(&SandboxPolicy::DangerFullAccess),
|
|
Err(codex_config::ConstraintError::InvalidValue {
|
|
field_name: "sandbox_mode",
|
|
candidate: "DangerFullAccess".into(),
|
|
allowed: "[ReadOnly, WorkspaceWrite]".into(),
|
|
requirement_source: RequirementSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_path,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_system_requirements_toml_file_uses_expected_suffix() {
|
|
assert!(
|
|
system_requirements_toml_file()
|
|
.expect("requirements.toml path")
|
|
.as_path()
|
|
.ends_with(
|
|
std::path::Path::new("OpenAI")
|
|
.join("Codex")
|
|
.join("requirements.toml")
|
|
)
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn windows_system_config_toml_file_uses_expected_suffix() {
|
|
assert!(
|
|
system_config_toml_file()
|
|
.expect("config.toml path")
|
|
.as_path()
|
|
.ends_with(
|
|
std::path::Path::new("OpenAI")
|
|
.join("Codex")
|
|
.join("config.toml")
|
|
)
|
|
);
|
|
}
|
|
}
|