mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add a loader for executor-local config layers (#37406)
## What changed - Add `load_local_config_layers` to return raw configuration and requirements layers with their sources, base directories, precedence order, and cloud insertion positions. - Support projecting layer stacks to selected TOML paths while preserving non-table override blockers and adjusting cloud insertion positions when empty layers are removed. - Reuse project discovery and trust handling for local reads, including linked-worktree hooks, without resolving relative paths or including profiles, session flags, thread layers, or cloud-provided values. ## Testing - Cover layer ordering, raw relative paths, legacy requirements, trust filtering, projection semantics, and untrusted linked-worktree hook handling. GitOrigin-RevId: a1098af5650ac44e5a230ba9a5fe918927986d05
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
use super::discover_project_layers;
|
||||
use super::layer_io;
|
||||
use super::load_config_toml_for_required_layer_raw;
|
||||
use super::load_requirements_toml;
|
||||
use super::load_root_checkout_project_config;
|
||||
use super::project_root_markers_from_config;
|
||||
use super::project_trust_context;
|
||||
use super::requirements_layers_from_legacy_scheme;
|
||||
use super::system_config_toml_file_with_overrides;
|
||||
use super::system_requirements_toml_file_with_overrides;
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::LoaderOverrides;
|
||||
use crate::RequirementSource;
|
||||
use crate::RequirementsLayerEntry;
|
||||
use crate::default_project_root_markers;
|
||||
use crate::merge_toml_values;
|
||||
use codex_file_system::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Executor-local configuration and requirements layers before schema-specific
|
||||
/// path resolution or requirements composition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocalConfigLayers {
|
||||
pub config: LocalTomlLayerStack<ConfigLayerSource>,
|
||||
pub requirements: LocalTomlLayerStack<RequirementSource>,
|
||||
}
|
||||
|
||||
impl LocalConfigLayers {
|
||||
/// Retains only the requested TOML paths and drops empty layers.
|
||||
///
|
||||
/// An empty path selects the entire document. RPC boundaries should reject
|
||||
/// that form if whole-document reads are not part of their contract.
|
||||
pub fn project(self, config_paths: &[Vec<String>], requirements_paths: &[Vec<String>]) -> Self {
|
||||
Self {
|
||||
config: self.config.project(config_paths),
|
||||
requirements: self.requirements.project(requirements_paths),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One ordered set of executor-local TOML layers.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocalTomlLayerStack<S> {
|
||||
/// Layers ordered from lowest to highest precedence.
|
||||
pub layers: Vec<LocalTomlLayer<S>>,
|
||||
/// Position at which a caller should insert cloud-provided layers.
|
||||
pub cloud_insertion_index: usize,
|
||||
}
|
||||
|
||||
impl<S> LocalTomlLayerStack<S> {
|
||||
fn project(self, paths: &[Vec<String>]) -> Self {
|
||||
let selectors = SelectorNode::from_paths(paths);
|
||||
let mut projected_layers = Vec::new();
|
||||
let mut cloud_insertion_index = 0;
|
||||
for (index, layer) in self.layers.into_iter().enumerate() {
|
||||
let Some(toml) = project_toml(&layer.toml, &selectors) else {
|
||||
continue;
|
||||
};
|
||||
if index < self.cloud_insertion_index {
|
||||
cloud_insertion_index += 1;
|
||||
}
|
||||
projected_layers.push(LocalTomlLayer { toml, ..layer });
|
||||
}
|
||||
Self {
|
||||
layers: projected_layers,
|
||||
cloud_insertion_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One executor-local TOML source with the directory used to interpret its
|
||||
/// relative paths.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LocalTomlLayer<S> {
|
||||
pub source: S,
|
||||
pub base_dir: AbsolutePathBuf,
|
||||
pub toml: TomlValue,
|
||||
}
|
||||
|
||||
/// Loads the fixed executor-local configuration sources used by environment
|
||||
/// config reads.
|
||||
///
|
||||
/// Cloud, selected profiles, session flags, and thread-provided layers are not
|
||||
/// included. Project discovery uses only the executor's system and base-user
|
||||
/// configuration.
|
||||
pub async fn load_local_config_layers(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_home: &Path,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> io::Result<LocalConfigLayers> {
|
||||
load_local_config_layers_with_overrides(fs, codex_home, cwd, &LoaderOverrides::default()).await
|
||||
}
|
||||
|
||||
pub(super) async fn load_local_config_layers_with_overrides(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_home: &Path,
|
||||
cwd: &AbsolutePathBuf,
|
||||
overrides: &LoaderOverrides,
|
||||
) -> io::Result<LocalConfigLayers> {
|
||||
let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let loaded_managed = layer_io::load_config_layers_internal(
|
||||
fs,
|
||||
codex_home.as_path(),
|
||||
overrides.clone(),
|
||||
/*strict_config*/ false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let system_file = system_config_toml_file_with_overrides(overrides)?;
|
||||
let system =
|
||||
load_config_toml_for_required_layer_raw(fs, &system_file, /*strict_config*/ false).await?;
|
||||
let user_file = codex_home.join(CONFIG_TOML_FILE);
|
||||
let user =
|
||||
load_config_toml_for_required_layer_raw(fs, &user_file, /*strict_config*/ false).await?;
|
||||
|
||||
let mut discovery_config = TomlValue::Table(toml::map::Map::new());
|
||||
merge_toml_values(&mut discovery_config, &system.toml);
|
||||
merge_toml_values(&mut discovery_config, &user.toml);
|
||||
let project_root_markers = project_root_markers_from_config(&discovery_config)?
|
||||
.unwrap_or_else(default_project_root_markers);
|
||||
let trust_context = project_trust_context(
|
||||
fs,
|
||||
&discovery_config,
|
||||
cwd,
|
||||
&project_root_markers,
|
||||
codex_home.as_path(),
|
||||
&user_file,
|
||||
)
|
||||
.await?;
|
||||
let project_layers = discover_project_layers(
|
||||
fs,
|
||||
cwd,
|
||||
&trust_context.project_root,
|
||||
&trust_context,
|
||||
codex_home.as_path(),
|
||||
/*strict_config*/ false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut config_layers = vec![
|
||||
LocalTomlLayer {
|
||||
source: ConfigLayerSource::System { file: system_file },
|
||||
base_dir: system.base_dir,
|
||||
toml: system.toml,
|
||||
},
|
||||
LocalTomlLayer {
|
||||
source: ConfigLayerSource::User {
|
||||
file: user_file,
|
||||
profile: None,
|
||||
},
|
||||
base_dir: user.base_dir,
|
||||
toml: user.toml,
|
||||
},
|
||||
];
|
||||
append_project_layers(fs, &mut config_layers, project_layers.layers).await?;
|
||||
|
||||
let requirements =
|
||||
local_requirements_layers(fs, codex_home.as_path(), overrides, loaded_managed.clone())
|
||||
.await?;
|
||||
append_legacy_config_layers(&mut config_layers, loaded_managed, &codex_home)?;
|
||||
|
||||
Ok(LocalConfigLayers {
|
||||
config: LocalTomlLayerStack {
|
||||
layers: config_layers,
|
||||
// Cloud config follows the required system layer.
|
||||
cloud_insertion_index: 1,
|
||||
},
|
||||
requirements,
|
||||
})
|
||||
}
|
||||
|
||||
async fn append_project_layers(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
output: &mut Vec<LocalTomlLayer<ConfigLayerSource>>,
|
||||
layers: Vec<super::DiscoveredProjectLayer>,
|
||||
) -> io::Result<()> {
|
||||
for layer in layers {
|
||||
if layer.disabled_reason.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut config = layer.config;
|
||||
if layer.hooks_config_folder_override.is_some()
|
||||
&& let Some(table) = config.as_table_mut()
|
||||
{
|
||||
table.remove("hooks");
|
||||
}
|
||||
output.push(LocalTomlLayer {
|
||||
source: ConfigLayerSource::Project {
|
||||
dot_codex_folder: layer.dot_codex_folder.clone(),
|
||||
},
|
||||
base_dir: layer.dot_codex_folder,
|
||||
toml: config,
|
||||
});
|
||||
|
||||
let Some(hooks_config_folder) = layer.hooks_config_folder_override else {
|
||||
continue;
|
||||
};
|
||||
let root_config =
|
||||
load_root_checkout_project_config(fs, &hooks_config_folder, /*is_trusted*/ true)
|
||||
.await?;
|
||||
let Some(hooks) = root_config.get("hooks") else {
|
||||
continue;
|
||||
};
|
||||
output.push(LocalTomlLayer {
|
||||
source: ConfigLayerSource::Project {
|
||||
dot_codex_folder: hooks_config_folder.clone(),
|
||||
},
|
||||
base_dir: hooks_config_folder,
|
||||
toml: TomlValue::Table(toml::map::Map::from_iter([(
|
||||
"hooks".to_string(),
|
||||
hooks.clone(),
|
||||
)])),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_legacy_config_layers(
|
||||
output: &mut Vec<LocalTomlLayer<ConfigLayerSource>>,
|
||||
loaded: layer_io::LoadedConfigLayers,
|
||||
codex_home: &AbsolutePathBuf,
|
||||
) -> io::Result<()> {
|
||||
if let Some(config) = loaded.managed_config {
|
||||
let base_dir = config.file.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Managed config file {} has no parent directory",
|
||||
config.file.as_path().display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
output.push(LocalTomlLayer {
|
||||
source: ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file },
|
||||
base_dir,
|
||||
toml: config.managed_config,
|
||||
});
|
||||
}
|
||||
if let Some(config) = loaded.managed_config_from_mdm {
|
||||
output.push(LocalTomlLayer {
|
||||
source: ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
base_dir: codex_home.clone(),
|
||||
toml: config.managed_config,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn local_requirements_layers(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_home: &Path,
|
||||
overrides: &LoaderOverrides,
|
||||
loaded_managed: layer_io::LoadedConfigLayers,
|
||||
) -> io::Result<LocalTomlLayerStack<RequirementSource>> {
|
||||
let system_file = system_requirements_toml_file_with_overrides(overrides)?;
|
||||
let system = load_requirements_toml(fs, &system_file).await?;
|
||||
let cloud_insertion_index = usize::from(system.is_some());
|
||||
let mut entries = Vec::new();
|
||||
entries.extend(system);
|
||||
entries.extend(requirements_layers_from_legacy_scheme(
|
||||
loaded_managed,
|
||||
codex_home,
|
||||
)?);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
entries.extend(
|
||||
super::macos::load_managed_admin_requirements_layer(
|
||||
overrides
|
||||
.macos_managed_config_requirements_base64
|
||||
.as_deref(),
|
||||
)
|
||||
.await?
|
||||
.map(|layer| layer.with_base_dir(codex_home)),
|
||||
);
|
||||
}
|
||||
|
||||
let mut layers = Vec::with_capacity(entries.len());
|
||||
for entry in entries {
|
||||
layers.push(local_requirements_layer(entry)?);
|
||||
}
|
||||
Ok(LocalTomlLayerStack {
|
||||
layers,
|
||||
cloud_insertion_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn local_requirements_layer(
|
||||
entry: RequirementsLayerEntry,
|
||||
) -> io::Result<LocalTomlLayer<RequirementSource>> {
|
||||
let (source, toml, base_dir) = entry.into_raw_parts()?;
|
||||
let base_dir = base_dir.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("requirements layer {source} has no base directory"),
|
||||
)
|
||||
})?;
|
||||
Ok(LocalTomlLayer {
|
||||
source,
|
||||
base_dir,
|
||||
toml,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SelectorNode {
|
||||
terminal: bool,
|
||||
children: BTreeMap<String, SelectorNode>,
|
||||
}
|
||||
|
||||
impl SelectorNode {
|
||||
fn from_paths(paths: &[Vec<String>]) -> Self {
|
||||
let mut root = Self::default();
|
||||
for path in paths {
|
||||
root.insert(path);
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn insert(&mut self, path: &[String]) {
|
||||
if self.terminal {
|
||||
return;
|
||||
}
|
||||
let Some((segment, remaining)) = path.split_first() else {
|
||||
self.terminal = true;
|
||||
self.children.clear();
|
||||
return;
|
||||
};
|
||||
self.children
|
||||
.entry(segment.clone())
|
||||
.or_default()
|
||||
.insert(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
fn project_toml(value: &TomlValue, selector: &SelectorNode) -> Option<TomlValue> {
|
||||
let projected = project_toml_value(value, selector);
|
||||
if !selector.terminal && projected.as_table().is_some_and(toml::map::Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
Some(projected)
|
||||
}
|
||||
|
||||
fn project_toml_value(value: &TomlValue, selector: &SelectorNode) -> TomlValue {
|
||||
if selector.terminal {
|
||||
return value.clone();
|
||||
}
|
||||
let Some(table) = value.as_table() else {
|
||||
// Preserve a non-table ancestor so it can still override lower layers.
|
||||
return value.clone();
|
||||
};
|
||||
let mut projected = toml::map::Map::new();
|
||||
for (key, value) in table {
|
||||
let Some(child_selector) = selector.children.get(key) else {
|
||||
continue;
|
||||
};
|
||||
projected.insert(key.clone(), project_toml_value(value, child_selector));
|
||||
}
|
||||
TomlValue::Table(projected)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod layer_io;
|
||||
mod local;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(test)]
|
||||
@@ -51,6 +52,11 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub use local::LocalConfigLayers;
|
||||
pub use local::LocalTomlLayer;
|
||||
pub use local::LocalTomlLayerStack;
|
||||
pub use local::load_local_config_layers;
|
||||
|
||||
#[cfg(unix)]
|
||||
const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
||||
|
||||
@@ -188,6 +194,7 @@ pub async fn load_config_layers_state(
|
||||
// requirements layers for backwards compatibility.
|
||||
requirements_layers.extend(requirements_layers_from_legacy_scheme(
|
||||
loaded_config_layers.clone(),
|
||||
codex_home,
|
||||
)?);
|
||||
requirements_layers.extend(managed_preferences_requirements_layer);
|
||||
}
|
||||
@@ -499,18 +506,36 @@ async fn load_config_toml_for_required_layer(
|
||||
strict_config: bool,
|
||||
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
|
||||
) -> io::Result<ConfigLayerEntry> {
|
||||
let loaded = load_config_toml_for_required_layer_raw(fs, toml_file, strict_config).await?;
|
||||
let toml_value = resolve_relative_paths_in_config_toml(loaded.toml, loaded.base_dir.as_path())?;
|
||||
|
||||
Ok(create_entry(toml_value))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LoadedTomlFile {
|
||||
toml: TomlValue,
|
||||
base_dir: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
async fn load_config_toml_for_required_layer_raw(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
toml_file: &AbsolutePathBuf,
|
||||
strict_config: bool,
|
||||
) -> io::Result<LoadedTomlFile> {
|
||||
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Config file {} has no parent directory",
|
||||
toml_file.as_path().display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let base_dir = AbsolutePathBuf::from_absolute_path(config_parent)?;
|
||||
let toml_file_uri = PathUri::from_abs_path(toml_file);
|
||||
let toml_value = match fs.read_file_text(&toml_file_uri, /*sandbox*/ None).await {
|
||||
Ok(contents) => {
|
||||
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Config file {} has no parent directory",
|
||||
toml_file.as_path().display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let config: TomlValue = toml::from_str(&contents).map_err(|err| {
|
||||
let config_error =
|
||||
config_error_from_toml(toml_file.as_path(), &contents, err.clone());
|
||||
@@ -524,7 +549,7 @@ async fn load_config_toml_for_required_layer(
|
||||
config_parent,
|
||||
)?;
|
||||
}
|
||||
resolve_relative_paths_in_config_toml(config, config_parent)
|
||||
Ok(config)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.kind() == io::ErrorKind::NotFound {
|
||||
@@ -541,7 +566,10 @@ async fn load_config_toml_for_required_layer(
|
||||
}
|
||||
}?;
|
||||
|
||||
Ok(create_entry(toml_value))
|
||||
Ok(LoadedTomlFile {
|
||||
toml: toml_value,
|
||||
base_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_config_toml_strictly(
|
||||
@@ -750,6 +778,7 @@ fn windows_program_data_dir_from_known_folder() -> io::Result<PathBuf> {
|
||||
|
||||
fn requirements_layers_from_legacy_scheme(
|
||||
loaded_config_layers: LoadedConfigLayers,
|
||||
codex_home: &Path,
|
||||
) -> io::Result<Vec<RequirementsLayerEntry>> {
|
||||
// List the file-backed legacy layer first because requirements layers are
|
||||
// composed lowest-precedence to highest-precedence, and MDM has higher
|
||||
@@ -762,18 +791,31 @@ fn requirements_layers_from_legacy_scheme(
|
||||
let layer_count =
|
||||
usize::from(managed_config.is_some()) + usize::from(managed_config_from_mdm.is_some());
|
||||
let mut layers = Vec::with_capacity(layer_count);
|
||||
for (source, config) in managed_config
|
||||
let codex_home = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
for (source, config, base_dir) in managed_config
|
||||
.map(|c| {
|
||||
(
|
||||
let base_dir = c.file.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Managed config file {} has no parent directory",
|
||||
c.file.as_path().display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok::<_, io::Error>((
|
||||
RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file },
|
||||
c.managed_config,
|
||||
)
|
||||
base_dir,
|
||||
))
|
||||
})
|
||||
.transpose()?
|
||||
.into_iter()
|
||||
.chain(managed_config_from_mdm.map(|config| {
|
||||
(
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
config.managed_config,
|
||||
codex_home.clone(),
|
||||
)
|
||||
}))
|
||||
{
|
||||
@@ -785,10 +827,13 @@ fn requirements_layers_from_legacy_scheme(
|
||||
)
|
||||
})?;
|
||||
|
||||
layers.push(RequirementsLayerEntry::from_toml_value(
|
||||
source,
|
||||
legacy_requirements_to_toml_value(legacy_config)?,
|
||||
));
|
||||
layers.push(
|
||||
RequirementsLayerEntry::from_toml_value(
|
||||
source,
|
||||
legacy_requirements_to_toml_value(legacy_config)?,
|
||||
)
|
||||
.with_base_dir(base_dir),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(layers)
|
||||
@@ -1212,6 +1257,20 @@ struct LoadedProjectLayers {
|
||||
startup_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiscoveredProjectLayer {
|
||||
dot_codex_folder: AbsolutePathBuf,
|
||||
config: TomlValue,
|
||||
disabled_reason: Option<String>,
|
||||
hooks_config_folder_override: Option<AbsolutePathBuf>,
|
||||
load_root_checkout_hooks: bool,
|
||||
}
|
||||
|
||||
struct DiscoveredProjectLayers {
|
||||
layers: Vec<DiscoveredProjectLayer>,
|
||||
startup_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -1226,6 +1285,52 @@ async fn load_project_layers(
|
||||
codex_home: &Path,
|
||||
strict_config: bool,
|
||||
) -> io::Result<LoadedProjectLayers> {
|
||||
let discovered = discover_project_layers(
|
||||
fs,
|
||||
cwd,
|
||||
project_root,
|
||||
trust_context,
|
||||
codex_home,
|
||||
strict_config,
|
||||
)
|
||||
.await?;
|
||||
let mut layers = Vec::with_capacity(discovered.layers.len());
|
||||
for layer in discovered.layers {
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(layer.config, layer.dot_codex_folder.as_path())?;
|
||||
let config = if layer.load_root_checkout_hooks {
|
||||
merge_root_checkout_project_hooks(
|
||||
fs,
|
||||
config,
|
||||
layer.hooks_config_folder_override.as_ref(),
|
||||
layer.disabled_reason.is_none(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
config
|
||||
};
|
||||
layers.push(project_layer_entry(
|
||||
&layer.dot_codex_folder,
|
||||
config,
|
||||
layer.disabled_reason,
|
||||
layer.hooks_config_folder_override,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(LoadedProjectLayers {
|
||||
layers,
|
||||
startup_warnings: discovered.startup_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn discover_project_layers(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root: &AbsolutePathBuf,
|
||||
trust_context: &ProjectTrustContext,
|
||||
codex_home: &Path,
|
||||
strict_config: bool,
|
||||
) -> io::Result<DiscoveredProjectLayers> {
|
||||
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());
|
||||
@@ -1282,12 +1387,13 @@ async fn load_project_layers(
|
||||
),
|
||||
));
|
||||
}
|
||||
layers.push(project_layer_entry(
|
||||
&dot_codex_abs,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
disabled_reason.clone(),
|
||||
hooks_config_folder_override.clone(),
|
||||
));
|
||||
layers.push(DiscoveredProjectLayer {
|
||||
dot_codex_folder: dot_codex_abs,
|
||||
config: TomlValue::Table(toml::map::Map::new()),
|
||||
disabled_reason,
|
||||
hooks_config_folder_override,
|
||||
load_root_checkout_hooks: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -1301,47 +1407,32 @@ async fn load_project_layers(
|
||||
)?;
|
||||
}
|
||||
let ignored_project_config_keys = sanitize_project_config(&mut config);
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
||||
let config = merge_root_checkout_project_hooks(
|
||||
fs,
|
||||
config,
|
||||
hooks_config_folder_override.as_ref(),
|
||||
decision.is_trusted(),
|
||||
)
|
||||
.await?;
|
||||
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
|
||||
startup_warnings.push(project_ignored_config_keys_warning(
|
||||
&dot_codex_abs,
|
||||
&ignored_project_config_keys,
|
||||
));
|
||||
}
|
||||
let entry = project_layer_entry(
|
||||
&dot_codex_abs,
|
||||
layers.push(DiscoveredProjectLayer {
|
||||
dot_codex_folder: dot_codex_abs,
|
||||
config,
|
||||
disabled_reason.clone(),
|
||||
hooks_config_folder_override.clone(),
|
||||
);
|
||||
layers.push(entry);
|
||||
disabled_reason,
|
||||
hooks_config_folder_override,
|
||||
load_root_checkout_hooks: true,
|
||||
});
|
||||
}
|
||||
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.
|
||||
let config = merge_root_checkout_project_hooks(
|
||||
fs,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
hooks_config_folder_override.as_ref(),
|
||||
decision.is_trusted(),
|
||||
)
|
||||
.await?;
|
||||
layers.push(project_layer_entry(
|
||||
&dot_codex_abs,
|
||||
config,
|
||||
layers.push(DiscoveredProjectLayer {
|
||||
dot_codex_folder: dot_codex_abs,
|
||||
config: TomlValue::Table(toml::map::Map::new()),
|
||||
disabled_reason,
|
||||
hooks_config_folder_override,
|
||||
));
|
||||
load_root_checkout_hooks: true,
|
||||
});
|
||||
} else {
|
||||
let config_file_display = config_file.as_path().display();
|
||||
return Err(io::Error::new(
|
||||
@@ -1353,7 +1444,7 @@ async fn load_project_layers(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LoadedProjectLayers {
|
||||
Ok(DiscoveredProjectLayers {
|
||||
layers,
|
||||
startup_warnings,
|
||||
})
|
||||
@@ -1370,43 +1461,10 @@ async fn merge_root_checkout_project_hooks(
|
||||
let Some(hooks_config_folder) = hooks_config_folder_override else {
|
||||
return Ok(config);
|
||||
};
|
||||
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
|
||||
let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file);
|
||||
let root_config = match fs
|
||||
.read_file_text(&hooks_config_file_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
let parsed: TomlValue = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
if is_trusted {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Error parsing project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
};
|
||||
resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())?
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"Failed to read project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
let root_config =
|
||||
load_root_checkout_project_config(fs, hooks_config_folder, is_trusted).await?;
|
||||
let root_config =
|
||||
resolve_relative_paths_in_config_toml(root_config, hooks_config_folder.as_path())?;
|
||||
|
||||
let Some(config_table) = config.as_table_mut() else {
|
||||
return Ok(config);
|
||||
@@ -1417,6 +1475,52 @@ async fn merge_root_checkout_project_hooks(
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn load_root_checkout_project_config(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
hooks_config_folder: &AbsolutePathBuf,
|
||||
is_trusted: bool,
|
||||
) -> io::Result<TomlValue> {
|
||||
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
|
||||
let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file);
|
||||
Ok(
|
||||
match fs
|
||||
.read_file_text(&hooks_config_file_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
let parsed: TomlValue = match toml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
if is_trusted {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Error parsing project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
};
|
||||
parsed
|
||||
}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"Failed to read project hooks config file {}: {err}",
|
||||
hooks_config_file.as_path().display()
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
/// The legacy mechanism for specifying admin-enforced configuration is to read
|
||||
/// from a file like `/etc/codex/managed_config.toml` that has the same
|
||||
/// structure as `config.toml` where fields like `approval_policy` can specify
|
||||
|
||||
@@ -70,10 +70,21 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
fn get_metadata<'a>(
|
||||
&'a self,
|
||||
_path: &'a PathUri,
|
||||
path: &'a PathUri,
|
||||
_sandbox: Option<&'a FileSystemSandboxContext>,
|
||||
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
|
||||
Box::pin(async move { unimplemented!("test filesystem only supports reads") })
|
||||
Box::pin(async move {
|
||||
let path = path.to_abs_path()?;
|
||||
let metadata = tokio::fs::symlink_metadata(path.as_path()).await?;
|
||||
Ok(FileMetadata {
|
||||
is_directory: metadata.is_dir(),
|
||||
is_file: metadata.is_file(),
|
||||
is_symlink: metadata.file_type().is_symlink(),
|
||||
size: metadata.len(),
|
||||
created_at_ms: 0,
|
||||
modified_at_ms: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn read_directory<'a>(
|
||||
@@ -254,3 +265,204 @@ model = "gpt-dev"
|
||||
.await
|
||||
.expect("profile-v2 should allow unrelated legacy profiles in base user config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_layer_projection_preserves_override_blockers_and_cloud_position() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let base_dir = AbsolutePathBuf::from_absolute_path(tmp.path()).expect("absolute base");
|
||||
let layer = |source, contents| LocalTomlLayer {
|
||||
source,
|
||||
base_dir: base_dir.clone(),
|
||||
toml: toml::from_str(contents).expect("valid TOML"),
|
||||
};
|
||||
let layers = LocalConfigLayers {
|
||||
config: LocalTomlLayerStack {
|
||||
layers: vec![
|
||||
layer(
|
||||
ConfigLayerSource::System {
|
||||
file: base_dir.join("system.toml"),
|
||||
},
|
||||
r#"ignored=true
|
||||
"literal.key"="literal"
|
||||
array=[1,2]
|
||||
[a]
|
||||
b=1
|
||||
c=2
|
||||
"#,
|
||||
),
|
||||
layer(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
"a=2\nignored=false\nonly_user=true",
|
||||
),
|
||||
layer(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
"[a]\nunrequested=true",
|
||||
),
|
||||
],
|
||||
cloud_insertion_index: 1,
|
||||
},
|
||||
requirements: LocalTomlLayerStack {
|
||||
layers: Vec::<LocalTomlLayer<RequirementSource>>::new(),
|
||||
cloud_insertion_index: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let only_user = layers.clone().project(&[vec!["only_user".into()]], &[]);
|
||||
assert_eq!(only_user.config.layers.len(), 1);
|
||||
assert_eq!(only_user.config.cloud_insertion_index, 0);
|
||||
|
||||
let projected = layers.project(
|
||||
&[
|
||||
vec!["a".into(), "b".into()],
|
||||
vec!["array".into(), "unused".into()],
|
||||
vec!["literal.key".into()],
|
||||
],
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
projected.config,
|
||||
LocalTomlLayerStack {
|
||||
layers: vec![
|
||||
layer(
|
||||
ConfigLayerSource::System {
|
||||
file: base_dir.join("system.toml"),
|
||||
},
|
||||
r#""literal.key"="literal"
|
||||
array=[1,2]
|
||||
[a]
|
||||
b=1"#,
|
||||
),
|
||||
layer(ConfigLayerSource::SessionFlags, "a=2"),
|
||||
layer(ConfigLayerSource::LegacyManagedConfigTomlFromMdm, "[a]"),
|
||||
],
|
||||
cloud_insertion_index: 1,
|
||||
}
|
||||
);
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in projected.config.layers {
|
||||
merge_toml_values(&mut merged, &layer.toml);
|
||||
}
|
||||
assert_eq!(
|
||||
merged.get("a"),
|
||||
Some(&TomlValue::Table(toml::map::Map::new()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_layers_keep_raw_paths_order_and_legacy_requirements() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let codex_home = tmp.path().join("codex-home");
|
||||
let project = tmp.path().join("project");
|
||||
let dot_codex = project.join(".codex");
|
||||
let system_dir = tmp.path().join("system");
|
||||
let managed_dir = tmp.path().join("managed");
|
||||
for dir in [&codex_home, &dot_codex, &system_dir, &managed_dir] {
|
||||
std::fs::create_dir_all(dir).expect("create fixture directory");
|
||||
}
|
||||
std::fs::write(project.join(".project-root"), "").expect("write project marker");
|
||||
|
||||
let project_key = project_trust_key(&project);
|
||||
let project_key = TomlValue::String(project_key).to_string();
|
||||
let user_config = |trust_level| {
|
||||
format!(
|
||||
"project_root_markers=[\".project-root\"]\nmodel_instructions_file=\"./user.md\"\n[projects.{project_key}]\ntrust_level=\"{trust_level}\""
|
||||
)
|
||||
};
|
||||
let user_file = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&user_file, user_config("trusted")).expect("write user config");
|
||||
let system_file = system_dir.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&system_file, "model_instructions_file = \"./system.md\"")
|
||||
.expect("write system config");
|
||||
std::fs::write(
|
||||
dot_codex.join(CONFIG_TOML_FILE),
|
||||
"model_instructions_file = \"./project.md\"\nopenai_base_url = \"https://ignored\"",
|
||||
)
|
||||
.expect("write project config");
|
||||
let managed_file = managed_dir.join("managed_config.toml");
|
||||
std::fs::write(
|
||||
&managed_file,
|
||||
"approval_policy = \"never\"\nsandbox_mode = \"workspace-write\"\nmodel_instructions_file = \"./managed.md\"",
|
||||
)
|
||||
.expect("write legacy managed config");
|
||||
let requirements_file = managed_dir.join("requirements.toml");
|
||||
std::fs::write(
|
||||
&requirements_file,
|
||||
"allowed_sandbox_modes = [\"read-only\"]\nlog_dir = \"./logs\"",
|
||||
)
|
||||
.expect("write system requirements");
|
||||
|
||||
let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_file.clone());
|
||||
overrides.system_config_path = Some(system_file.clone());
|
||||
overrides.system_requirements_path = Some(requirements_file.clone());
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&project).expect("absolute cwd");
|
||||
let layers = local::load_local_config_layers_with_overrides(
|
||||
&TestFileSystem,
|
||||
&codex_home,
|
||||
&cwd,
|
||||
&overrides,
|
||||
)
|
||||
.await
|
||||
.expect("load local layers");
|
||||
|
||||
assert_eq!(
|
||||
layers
|
||||
.config
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| layer.base_dir.to_path_buf())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
system_dir.clone(),
|
||||
codex_home.clone(),
|
||||
dot_codex.clone(),
|
||||
managed_dir.clone(),
|
||||
]
|
||||
);
|
||||
assert_eq!(layers.config.cloud_insertion_index, 1);
|
||||
assert_eq!(layers.requirements.cloud_insertion_index, 1);
|
||||
assert_eq!(
|
||||
(
|
||||
layers.config.layers[2].toml.clone(),
|
||||
layers.config.layers[3]
|
||||
.toml
|
||||
.get("model_instructions_file")
|
||||
.cloned(),
|
||||
layers.requirements.layers[0]
|
||||
.toml
|
||||
.get("log_dir")
|
||||
.cloned(),
|
||||
layers.requirements.layers[1].toml.clone(),
|
||||
),
|
||||
(
|
||||
toml::from_str("model_instructions_file = \"./project.md\"")
|
||||
.expect("project TOML"),
|
||||
Some(TomlValue::String("./managed.md".into())),
|
||||
Some(TomlValue::String("./logs".into())),
|
||||
toml::from_str(
|
||||
"allowed_approval_policies=[\"never\"]\nallowed_sandbox_modes=[\"read-only\",\"workspace-write\"]"
|
||||
)
|
||||
.expect("legacy requirements TOML"),
|
||||
)
|
||||
);
|
||||
|
||||
std::fs::write(&user_file, user_config("untrusted")).expect("write user config");
|
||||
let layers = local::load_local_config_layers_with_overrides(
|
||||
&TestFileSystem,
|
||||
&codex_home,
|
||||
&cwd,
|
||||
&overrides,
|
||||
)
|
||||
.await
|
||||
.expect("load local layers");
|
||||
assert_eq!(
|
||||
layers
|
||||
.config
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| matches!(layer.source, ConfigLayerSource::Project { .. }))
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,19 @@ impl RequirementsLayerEntry {
|
||||
self.base_dir = Some(base_dir);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_raw_parts(
|
||||
self,
|
||||
) -> Result<(RequirementSource, TomlValue, Option<AbsolutePathBuf>), RequirementsCompositionError>
|
||||
{
|
||||
let Self {
|
||||
source,
|
||||
toml,
|
||||
base_dir,
|
||||
} = self;
|
||||
let toml = parse_layer_toml(&toml, &source)?;
|
||||
Ok((source, toml, base_dir))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -2748,6 +2748,52 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_untrusted_linked_worktree_does_not_read_root_hooks() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let worktree_root = tmp.path().join("worktree");
|
||||
|
||||
tokio::fs::create_dir_all(worktree_root.join(".codex")).await?;
|
||||
tokio::fs::create_dir_all(repo_root.join(".codex")).await?;
|
||||
write_linked_worktree_pointer(&repo_root, &worktree_root).await?;
|
||||
tokio::fs::write(worktree_root.join(".codex").join(CONFIG_TOML_FILE), "foo =").await?;
|
||||
tokio::fs::write(repo_root.join(".codex").join(CONFIG_TOML_FILE), [0xff]).await?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&repo_root,
|
||||
TrustLevel::Untrusted,
|
||||
/*project_root_markers*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&worktree_root)?;
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
let project_layers = layers
|
||||
.all_layers_high_to_low()
|
||||
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(project_layers.len(), 1);
|
||||
assert!(project_layers[0].disabled_reason.is_some());
|
||||
assert_eq!(
|
||||
project_layers[0].config,
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml()
|
||||
-> std::io::Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user