Compare commits

...

2 Commits

Author SHA1 Message Date
jif-oai
6c913292a0 fix clippy 2026-04-21 12:53:12 +01:00
jif-oai
d6a1469e69 feat[part-1]: profile v2 2026-04-21 12:47:21 +01:00
7 changed files with 259 additions and 35 deletions

View File

@@ -17,6 +17,7 @@ use toml::Value as TomlValue;
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
#[derive(Debug, Default, Clone)]
pub struct LoaderOverrides {
pub user_config_path: Option<PathBuf>,
pub managed_config_path: Option<PathBuf>,
pub ignore_user_config: bool,
pub ignore_user_and_project_exec_policy_rules: bool,
@@ -43,6 +44,7 @@ impl LoaderOverrides {
/// This is intended for tests that supply an explicit managed config fixture.
pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self {
Self {
user_config_path: None,
managed_config_path: Some(managed_config_path),
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
@@ -150,7 +152,12 @@ pub struct ConfigLayerStack {
/// later entries in the Vec override earlier ones.
layers: Vec<ConfigLayerEntry>,
/// Index into [layers] of the user config layer, if any.
/// Index into [layers] of the active user config layer, if any.
///
/// When profile config is active, there can be more than one user layer:
/// the base `$CODEX_HOME/config.toml` layer followed by the profile override
/// layer. This index points at the highest-precedence user layer because that
/// is the writable layer for profile-aware edits.
user_layer_index: Option<usize>,
/// Constraints that must be enforced when deriving a [Config] from the
@@ -194,7 +201,7 @@ impl ConfigLayerStack {
self.ignore_user_and_project_exec_policy_rules
}
/// Returns the raw user config layer, if any.
/// Returns the active raw user config layer, if any.
///
/// This does not merge other config layers or apply any requirements.
pub fn get_user_layer(&self) -> Option<&ConfigLayerEntry> {
@@ -202,6 +209,46 @@ impl ConfigLayerStack {
.and_then(|index| self.layers.get(index))
}
pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> {
let layer = self.get_user_layer()?;
let ConfigLayerSource::User { file } = &layer.name else {
return None;
};
Some(file)
}
/// Returns all user config layers in precedence order.
pub fn get_user_layers(
&self,
ordering: ConfigLayerStackOrdering,
include_disabled: bool,
) -> Vec<&ConfigLayerEntry> {
self.get_layers(ordering, include_disabled)
.into_iter()
.filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. }))
.collect()
}
/// Returns the merged config from enabled user layers only.
///
/// When profile config is active, this includes the base user config followed
/// by the profile override config.
pub fn effective_user_config(&self) -> Option<TomlValue> {
let user_layers = self.get_user_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
);
if user_layers.is_empty() {
return None;
}
let mut merged = TomlValue::Table(toml::map::Map::new());
for layer in user_layers {
merge_toml_values(&mut merged, &layer.config);
}
Some(merged)
}
pub fn requirements(&self) -> &ConfigRequirements {
&self.requirements
}
@@ -325,7 +372,7 @@ impl ConfigLayerStack {
}
/// Ensures precedence ordering of config layers is correct. Returns the index
/// of the user config layer, if any (at most one should exist).
/// of the active user config layer, if any.
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
if !layers.iter().map(|layer| &layer.name).is_sorted() {
return Err(std::io::Error::new(
@@ -335,19 +382,13 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<
}
// The previous check ensured `layers` is sorted by precedence, so now we
// further verify that:
// 1. There is at most one user config layer.
// 2. Project layers are ordered from root to cwd.
// further verify that project layers are ordered from root to cwd. Multiple
// user layers are allowed so a profile override can layer on top of the base
// user config.
let mut user_layer_index: Option<usize> = None;
let mut previous_project_dot_codex_folder: Option<&AbsolutePathBuf> = None;
for (index, layer) in layers.iter().enumerate() {
if matches!(layer.name, ConfigLayerSource::User { .. }) {
if user_layer_index.is_some() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"multiple user config layers found",
));
}
user_layer_index = Some(index);
}

View File

@@ -32,3 +32,52 @@ no_memories_if_mcp_or_web_search = true
"legacy key should be canonicalized before origin recording"
);
}
#[test]
fn active_user_layer_is_highest_precedence_user_layer() {
let base_file = AbsolutePathBuf::from_absolute_path(PathBuf::from("/tmp/codex/config.toml"))
.expect("absolute base path");
let profile_file =
AbsolutePathBuf::from_absolute_path(PathBuf::from("/tmp/codex/work.config.toml"))
.expect("absolute profile path");
let base_layer = ConfigLayerEntry::new(
ConfigLayerSource::User { file: base_file },
toml::from_str(
r#"
model = "base"
approval_policy = "on-failure"
"#,
)
.expect("base config"),
);
let profile_layer = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: profile_file.clone(),
},
toml::from_str(r#"model = "profile""#).expect("profile config"),
);
let stack = ConfigLayerStack::new(
vec![base_layer, profile_layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("multiple user layers should be valid");
assert_eq!(stack.get_user_config_file(), Some(&profile_file));
assert_eq!(
stack
.effective_user_config()
.expect("merged user config")
.get("model")
.and_then(toml::Value::as_str),
Some("profile")
);
assert_eq!(
stack
.effective_user_config()
.expect("merged user config")
.get("approval_policy")
.and_then(toml::Value::as_str),
Some("on-failure")
);
}

View File

@@ -3348,6 +3348,24 @@ async fn set_model_updates_defaults() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn profile_v2_config_path_accepts_only_plain_names() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
assert_eq!(
resolve_profile_v2_config_path(codex_home.path(), "work")?,
codex_home.path().join("work.config.toml")
);
for invalid in ["", ".", "..", "nested/work", "nested\\work", "work.toml"] {
assert!(
resolve_profile_v2_config_path(codex_home.path(), invalid).is_err(),
"{invalid:?} should be rejected"
);
}
Ok(())
}
#[tokio::test]
async fn set_model_overwrites_existing_model() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;

View File

@@ -133,6 +133,9 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0";
pub const CONFIG_TOML_FILE: &str = "config.toml";
// TODO jif
#[cfg(test)]
const CONFIG_PROFILE_V2_SUFFIX: &str = ".config.toml";
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
@@ -903,6 +906,31 @@ impl Config {
}
}
// TODO(jif) This will be used in later branches
#[cfg(test)]
pub fn resolve_profile_v2_config_path(
codex_home: &Path,
profile_name: &str,
) -> std::io::Result<PathBuf> {
if profile_name.is_empty()
|| profile_name == "."
|| profile_name == ".."
|| profile_name.contains(std::path::MAIN_SEPARATOR)
|| profile_name.contains('/')
|| profile_name.contains('\\')
|| profile_name.ends_with(".toml")
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"invalid --profile-v2 value `{profile_name}`; pass a plain name such as `work`"
),
));
}
Ok(codex_home.join(format!("{profile_name}{CONFIG_PROFILE_V2_SUFFIX}")))
}
/// DEPRECATED: Use [Config::load_with_cli_overrides()] instead because working
/// with [ConfigToml] directly means that [ConfigRequirements] have not been
/// applied yet, which risks failing to enforce required constraints.

View File

@@ -42,6 +42,7 @@ pub(super) async fn load_config_layers_internal(
) -> io::Result<LoadedConfigLayers> {
#[cfg(target_os = "macos")]
let LoaderOverrides {
user_config_path: _,
managed_config_path,
managed_preferences_base64,
..
@@ -49,6 +50,7 @@ pub(super) async fn load_config_layers_internal(
#[cfg(not(target_os = "macos"))]
let LoaderOverrides {
user_config_path: _,
managed_config_path,
..
} = overrides;

View File

@@ -110,6 +110,7 @@ pub(crate) async fn first_layer_config_error_from_entries(
/// - system `/etc/codex/config.toml` (Unix) or
/// `%ProgramData%\OpenAI\Codex\config.toml` (Windows)
/// - user `${CODEX_HOME}/config.toml`
/// - profile `${CODEX_HOME}/<name>.config.toml`, when selected
/// - 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)
@@ -134,6 +135,7 @@ pub async fn load_config_layers_state(
thread_config_loader: &dyn ThreadConfigLoader,
host_name: Option<&str>,
) -> io::Result<ConfigLayerStack> {
let profile_user_config_path = overrides.user_config_path.clone();
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
overrides.ignore_user_and_project_exec_policy_rules;
@@ -219,29 +221,19 @@ pub async fn load_config_layers_state(
.await?;
layers.push(system_layer);
// Add a layer for $CODEX_HOME/config.toml so folder-derived resources such
// as rules/ can still be discovered. When user config is ignored, preserve
// the layer metadata without reading config.toml.
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
let user_layer = if ignore_user_config {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
TomlValue::Table(toml::map::Map::new()),
)
} else {
load_config_toml_for_required_layer(fs, &user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
config_toml,
)
})
.await?
// Add the base user config layer. When profile-v2 is selected, add the
// profile config as a second user layer on top so the profile only needs to
// contain overrides.
let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home);
layers.push(load_user_config_layer(fs, &base_user_file, ignore_user_config).await?);
let active_user_file = match profile_user_config_path {
Some(path) => AbsolutePathBuf::from_absolute_path(path)?,
None => base_user_file.clone(),
};
layers.push(user_layer);
if active_user_file != base_user_file {
layers.push(load_user_config_layer(fs, &active_user_file, ignore_user_config).await?);
}
if let Some(cwd) = cwd {
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
@@ -271,7 +263,7 @@ pub async fn load_config_layers_state(
&cwd,
&project_root_markers,
codex_home,
&user_file,
&active_user_file,
)
.await
{
@@ -363,6 +355,31 @@ pub async fn load_config_layers_state(
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules))
}
async fn load_user_config_layer(
fs: &dyn ExecutorFileSystem,
user_file: &AbsolutePathBuf,
ignore_user_config: bool,
) -> io::Result<ConfigLayerEntry> {
if ignore_user_config {
return Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
TomlValue::Table(toml::map::Map::new()),
));
}
load_config_toml_for_required_layer(fs, user_file, |config_toml| {
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
},
config_toml,
)
})
.await
}
fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
match layers
.iter()

View File

@@ -368,6 +368,75 @@ async fn returns_empty_when_all_layers_missing() {
}
}
#[tokio::test]
async fn selected_user_config_file_layers_over_base_user_config() {
let tmp = tempdir().expect("tempdir");
let managed_path = tmp.path().join("managed_config.toml");
let selected_config = tmp.path().join("work.config.toml");
std::fs::write(
tmp.path().join(CONFIG_TOML_FILE),
r#"
model = "gpt-main"
approval_policy = "on-failure"
"#,
)
.expect("write default user config");
std::fs::write(&selected_config, r#"model = "gpt-work""#).expect("write selected user config");
let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
overrides.user_config_path = Some(selected_config.clone());
let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd");
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
tmp.path(),
Some(cwd),
&[] as &[(String, TomlValue)],
overrides,
CloudRequirementsLoader::default(),
&codex_config::NoopThreadConfigLoader,
/*host_name*/ None,
)
.await
.expect("load layers");
let user_layers = layers.get_user_layers(
super::ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ false,
);
assert_eq!(user_layers.len(), 2);
assert_eq!(
user_layers[0].name,
super::ConfigLayerSource::User {
file: AbsolutePathBuf::from_absolute_path(tmp.path().join(CONFIG_TOML_FILE))
.expect("base user config path")
}
);
let user_layer = layers.get_user_layer().expect("selected user layer");
assert_eq!(
user_layer.name,
super::ConfigLayerSource::User {
file: AbsolutePathBuf::from_absolute_path(&selected_config)
.expect("selected user config path")
}
);
assert_eq!(
layers
.effective_config()
.get("model")
.and_then(TomlValue::as_str),
Some("gpt-work")
);
assert_eq!(
layers
.effective_config()
.get("approval_policy")
.and_then(TomlValue::as_str),
Some("on-failure")
);
}
#[tokio::test]
async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> {
let tmp = tempdir()?;