feat: add layered --profile-v2 config files (#17141)

## Why

`--profile-v2 <name>` gives launchers and runtime entry points a named
profile config without making each profile duplicate the base user
config. The base `$CODEX_HOME/config.toml` still loads first, then
`$CODEX_HOME/<name>.config.toml` layers above it and becomes the active
writable user config for that session.

That keeps shared defaults, plugin/MCP setup, and managed/user
constraints in one place while letting a named profile override only the
pieces that need to differ.

## What Changed

- Added the shared `--profile-v2 <name>` runtime option with validated
plain names, now represented by `ProfileV2Name`.
- Extended config layer state so the base user config and selected
profile config are both `User` layers; APIs expose the active user layer
and merged effective user config.
- Threaded profile selection through runtime entry points: `codex`,
`codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex
debug prompt-input`.
- Made user-facing config writes go to the selected profile file when
active, including TUI/settings persistence, app-server config writes,
and MCP/app tool approval persistence.
- Made plugin, marketplace, MCP, hooks, and config reload paths read
from the merged user config so base and profile layers both participate.
- Updated app-server config layer schemas to mark profile-backed user
layers.

## Limits

`--profile-v2` is still rejected for config-management subcommands such
as feature, MCP, and marketplace edits. Those paths remain tied to the
base `config.toml` until they have explicit profile-selection semantics.

Some adjacent background writes may still update base or global state
rather than the selected profile:

- marketplace auto-upgrade metadata
- automatic MCP dependency installs from skills
- remote plugin sync or uninstall config edits
- personality migration marker/default writes

## Verification

Added targeted coverage for profile name validation, layer
ordering/merging, selected-profile writes, app-server config writes,
session hot reload, plugin config merging, hooks/config fixture updates,
and MCP/app approval persistence.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
jif-oai
2026-05-14 15:16:15 +02:00
committed by GitHub
parent 17cd321c32
commit deedf3b2c4
55 changed files with 1302 additions and 241 deletions

View File

@@ -507,7 +507,7 @@ impl ExternalAgentConfigService {
Ok(config) => {
let configured_plugin_ids = config
.config_layer_stack
.get_user_layer()
.get_active_user_layer()
.and_then(|user_layer| user_layer.config.get("plugins"))
.and_then(|plugins| {
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {

View File

@@ -62,6 +62,10 @@ impl ConfigManager {
self.codex_home.as_path()
}
pub(crate) fn user_config_path(&self) -> std::io::Result<AbsolutePathBuf> {
self.loader_overrides.user_config_path(self.codex_home())
}
pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
@@ -164,6 +168,16 @@ impl ConfigManager {
self.current_cli_overrides(),
)
.await?;
if self.loader_overrides.user_config_path.is_some()
|| self.loader_overrides.user_config_profile.is_some()
{
let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?;
config.config_layer_stack = config.config_layer_stack.with_user_config_profile(
&user_config_path,
self.loader_overrides.user_config_profile.as_ref(),
TomlValue::Table(toml::map::Map::new()),
);
}
self.apply_runtime_feature_enablement(&mut config);
self.apply_arg0_paths(&mut config);
Ok(config)

View File

@@ -196,8 +196,9 @@ impl ConfigManager {
expected_version: Option<String>,
edits: Vec<(String, JsonValue, MergeStrategy)>,
) -> Result<ConfigWriteResponse, ConfigManagerError> {
let allowed_path =
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
let allowed_path = self
.user_config_path()
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
let provided_path = match file_path {
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
@@ -215,7 +216,7 @@ impl ConfigManager {
.load_thread_agnostic_config()
.await
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
let user_layer = match layers.get_user_layer() {
let user_layer = match layers.get_active_user_layer() {
Some(layer) => Cow::Borrowed(layer),
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
};
@@ -305,7 +306,7 @@ impl ConfigManager {
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::new(self.codex_home())
ConfigEditsBuilder::for_config_path(provided_path.as_path())
.with_edits(config_edits)
.apply()
.await
@@ -321,7 +322,7 @@ impl ConfigManager {
Ok(ConfigWriteResponse {
status,
version: updated_layers
.get_user_layer()
.get_active_user_layer()
.ok_or_else(|| {
ConfigManagerError::write(
ConfigWriteErrorCode::UserLayerNotFound,
@@ -375,6 +376,7 @@ async fn create_empty_user_layer(
Ok(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_toml.clone(),
profile: None,
},
toml_value,
))
@@ -574,7 +576,7 @@ fn override_message(layer: &ConfigLayerSource) -> String {
dot_codex_folder.display(),
),
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
ConfigLayerSource::User { file } => {
ConfigLayerSource::User { file, .. } => {
format!("Overridden by user config: {}", file.display())
}
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
@@ -594,7 +596,7 @@ fn compute_override_metadata(
effective: &TomlValue,
segments: &[String],
) -> Option<OverriddenMetadata> {
let user_value = match layers.get_user_layer() {
let user_value = match layers.get_active_user_layer() {
Some(user_layer) => value_at_path(&user_layer.config, segments),
None => return None,
};

View File

@@ -293,7 +293,8 @@ async fn read_includes_origins_and_layers() {
assert_eq!(
layers.get(1).unwrap().name,
ConfigLayerSource::User {
file: user_file.clone()
file: user_file.clone(),
profile: None,
}
);
assert!(matches!(
@@ -454,6 +455,80 @@ async fn write_value_defaults_to_user_config_path() {
);
}
#[tokio::test]
async fn write_value_defaults_to_selected_user_config_path() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "").unwrap();
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path =
Some(AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path"));
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "model".to_string(),
value: serde_json::json!("gpt-work"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write succeeds");
assert_eq!(
std::fs::read_to_string(&selected_path).expect("read selected config"),
"model = \"gpt-work\"\n"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read main config"),
"model = \"gpt-main\""
);
}
#[tokio::test]
async fn load_default_config_preserves_selected_user_config_path_after_load_error() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
let selected_path = tmp.path().join("work.config.toml");
std::fs::write(&selected_path, "not valid toml").unwrap();
let selected_file =
AbsolutePathBuf::from_absolute_path(&selected_path).expect("selected config path");
let mut loader_overrides =
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
loader_overrides.user_config_path = Some(selected_file.clone());
loader_overrides.user_config_profile = Some("work".parse().expect("profile-v2 name"));
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
loader_overrides,
CloudRequirementsLoader::default(),
);
service
.load_latest_config(/*fallback_cwd*/ None)
.await
.expect_err("selected config should fail to load");
let config = service
.load_default_config()
.await
.expect("default config loads after selected config error");
assert_eq!(
config.config_layer_stack.get_user_config_file(),
Some(&selected_file)
);
}
#[tokio::test]
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let tmp = tempdir().expect("tempdir");
@@ -665,7 +740,10 @@ async fn read_reports_managed_overrides_user_and_session_flags() {
assert_eq!(layers.get(1).unwrap().name, ConfigLayerSource::SessionFlags);
assert_eq!(
layers.get(2).unwrap().name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
}

View File

@@ -82,6 +82,7 @@ sandbox_mode = "workspace-write"
origins.get("model").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
@@ -144,6 +145,7 @@ allowed_domains = ["example.com"]
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -153,6 +155,7 @@ allowed_domains = ["example.com"]
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
let layers = layers.expect("layers present");
@@ -297,6 +300,7 @@ default_tools_approval_mode = "prompt"
origins.get("apps.app1.enabled").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -306,6 +310,7 @@ default_tools_approval_mode = "prompt"
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
assert_eq!(
@@ -315,6 +320,7 @@ default_tools_approval_mode = "prompt"
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -459,6 +465,7 @@ writable_roots = [{}]
origins.get("sandbox_mode").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -485,6 +492,7 @@ writable_roots = [{}]
.name,
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
}
);
@@ -728,7 +736,10 @@ fn assert_layers_user_then_optional_system(
assert_eq!(layers.len(), first_index + 2);
assert_eq!(
layers[first_index].name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 1].name,
@@ -756,7 +767,10 @@ fn assert_layers_managed_user_then_optional_system(
);
assert_eq!(
layers[first_index + 1].name,
ConfigLayerSource::User { file: user_file }
ConfigLayerSource::User {
file: user_file,
profile: None
}
);
assert!(matches!(
layers[first_index + 2].name,