mirror of
https://github.com/openai/codex.git
synced 2026-09-17 12:23:33 +00:00
feat: support in-repo .codex/config.toml entries as sources of config info
This commit is contained in:
@@ -240,6 +240,14 @@ pub enum ConfigLayerSource {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
|
||||
/// Path to a .codex/ folder within a project. There could be multiple of
|
||||
/// these between `cwd` and the project/repo root.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
Project {
|
||||
dot_codex_folder: AbsolutePathBuf,
|
||||
},
|
||||
|
||||
/// Session-layer overrides supplied via `-c`/`--config`.
|
||||
SessionFlags,
|
||||
|
||||
@@ -247,6 +255,8 @@ pub enum ConfigLayerSource {
|
||||
/// as the last layer on top of everything else. This scheme did not quite
|
||||
/// work out as intended, but we keep this variant as a "best effort" while
|
||||
/// we phase out `managed_config.toml` in favor of `requirements.toml`.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase")]
|
||||
LegacyManagedConfigTomlFromFile {
|
||||
file: AbsolutePathBuf,
|
||||
},
|
||||
@@ -262,6 +272,7 @@ impl ConfigLayerSource {
|
||||
ConfigLayerSource::Mdm { .. } => 0,
|
||||
ConfigLayerSource::System { .. } => 10,
|
||||
ConfigLayerSource::User { .. } => 20,
|
||||
ConfigLayerSource::Project { .. } => 25,
|
||||
ConfigLayerSource::SessionFlags => 30,
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
|
||||
|
||||
@@ -556,6 +556,11 @@ fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("Overridden by managed config (system): {}", file.display())
|
||||
}
|
||||
ConfigLayerSource::Project { dot_codex_folder } => format!(
|
||||
"Overridden by project config: {}/{}",
|
||||
dot_codex_folder.display(),
|
||||
CONFIG_TOML_FILE
|
||||
),
|
||||
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
|
||||
ConfigLayerSource::User { file } => {
|
||||
format!("Overridden by user config: {}", file.display())
|
||||
|
||||
@@ -17,9 +17,11 @@ use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use serde::Deserialize;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
pub use config_requirements::ConfigRequirements;
|
||||
@@ -127,8 +129,11 @@ pub async fn load_config_layers_state(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(mbolin): Add layers for cwd, tree, and repo config files.
|
||||
let _ = cwd;
|
||||
if let Some(cwd) = cwd {
|
||||
let project_root = find_project_root(&cwd).await?;
|
||||
let project_layers = load_project_layers(&cwd, &project_root).await?;
|
||||
layers.extend(project_layers);
|
||||
}
|
||||
|
||||
// Add a layer for runtime overrides from the CLI or UI, if any exist.
|
||||
if !cli_overrides.is_empty() {
|
||||
@@ -235,6 +240,85 @@ async fn load_requirements_from_legacy_scheme(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_project_root(cwd: &AbsolutePathBuf) -> io::Result<AbsolutePathBuf> {
|
||||
for ancestor in cwd.as_path().ancestors() {
|
||||
let git_dir = ancestor.join(".git");
|
||||
if tokio::fs::metadata(&git_dir).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 si the highest precedence).
|
||||
async fn load_project_layers(
|
||||
cwd: &AbsolutePathBuf,
|
||||
project_root: &AbsolutePathBuf,
|
||||
) -> io::Result<Vec<ConfigLayerEntry>> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
let mut current = Some(cwd.as_path());
|
||||
while let Some(dir) = current {
|
||||
dirs.push(dir.to_path_buf());
|
||||
if dir == project_root.as_path() {
|
||||
break;
|
||||
}
|
||||
current = dir.parent();
|
||||
}
|
||||
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 dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?;
|
||||
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?;
|
||||
match tokio::fs::read_to_string(&config_file).await {
|
||||
Ok(contents) => {
|
||||
let _guard = AbsolutePathBufGuard::new(dot_codex_abs.as_path());
|
||||
let config: TomlValue = toml::from_str(&contents).map_err(|e| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"Error parsing project config file {}: {e}",
|
||||
config_file.as_path().display(),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
layers.push(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: dot_codex_abs,
|
||||
},
|
||||
config,
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
if err.kind() != io::ErrorKind::NotFound {
|
||||
return Err(io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
"Failed to read project config file {}: {err}",
|
||||
config_file.as_path().display(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -171,6 +171,7 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
|
||||
}
|
||||
|
||||
let mut user_layer_index: Option<usize> = None;
|
||||
let mut previous_project: Option<&AbsolutePathBuf> = None;
|
||||
for (index, layer) in layers.iter().enumerate() {
|
||||
if matches!(layer.name, ConfigLayerSource::User { .. }) {
|
||||
if user_layer_index.is_some() {
|
||||
@@ -181,6 +182,26 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
|
||||
}
|
||||
user_layer_index = Some(index);
|
||||
}
|
||||
|
||||
if let ConfigLayerSource::Project {
|
||||
dot_codex_folder: current,
|
||||
} = &layer.name
|
||||
{
|
||||
if let Some(previous) = previous_project {
|
||||
if previous == current
|
||||
|| !current
|
||||
.as_path()
|
||||
.ancestors()
|
||||
.any(|ancestor| ancestor == previous.as_path().parent().unwrap_or_else(|| previous.as_path()))
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"project layers are not ordered from root to cwd",
|
||||
));
|
||||
}
|
||||
}
|
||||
previous_project = Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(user_layer_index)
|
||||
|
||||
@@ -208,3 +208,58 @@ allowed_approval_policies = ["never", "on-request"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layers_prefer_closest_cwd() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
std::fs::create_dir_all(nested.join(".codex")).expect("dirs");
|
||||
std::fs::create_dir_all(project_root.join(".codex")).expect("dirs");
|
||||
std::fs::write(project_root.join(".git"), "gitdir: here").expect("git marker");
|
||||
|
||||
std::fs::write(
|
||||
project_root.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"foo = \"root\"\n",
|
||||
)
|
||||
.expect("root config");
|
||||
std::fs::write(
|
||||
nested.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"foo = \"child\"\n",
|
||||
)
|
||||
.expect("nested config");
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
std::fs::create_dir_all(&codex_home).expect("home");
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested).expect("cwd");
|
||||
let layers = load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
)
|
||||
.await
|
||||
.expect("load layers");
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.layers_high_to_low()
|
||||
.into_iter()
|
||||
.filter_map(|layer| match &layer.name {
|
||||
super::ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(project_layers.len(), 2);
|
||||
assert_eq!(project_layers[0].as_path(), nested.join(".codex").as_path());
|
||||
assert_eq!(
|
||||
project_layers[1].as_path(),
|
||||
project_root.join(".codex").as_path()
|
||||
);
|
||||
|
||||
let config = layers.effective_config();
|
||||
let foo = config
|
||||
.get("foo")
|
||||
.and_then(TomlValue::as_str)
|
||||
.expect("foo entry");
|
||||
assert_eq!(foo, "child");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user