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
co-authored by Codex
parent 17cd321c32
commit deedf3b2c4
55 changed files with 1302 additions and 241 deletions
+4
View File
@@ -125,6 +125,7 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError as AppServerTurnError;
use codex_app_server_protocol::TurnStatus;
use codex_config::ConfigLayerStackOrdering;
use codex_config::LoaderOverrides;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_exec_server::EnvironmentManager;
@@ -465,6 +466,7 @@ pub(crate) struct App {
pub(crate) active_profile: Option<String>,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
runtime_approval_policy_override: Option<AskForApproval>,
runtime_permission_profile_override: Option<PermissionProfile>,
@@ -629,6 +631,7 @@ impl App {
mut config: Config,
cli_kv_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
loader_overrides: LoaderOverrides,
active_profile: Option<String>,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -900,6 +903,7 @@ See the Codex keymap documentation for supported actions and examples."
active_profile,
cli_kv_overrides,
harness_overrides,
loader_overrides,
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
+4 -3
View File
@@ -15,6 +15,7 @@ impl App {
.codex_home(self.config.codex_home.to_path_buf())
.cli_overrides(self.cli_kv_overrides.clone())
.harness_overrides(overrides)
.loader_overrides(self.loader_overrides.clone())
.build()
.await
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
@@ -170,7 +171,7 @@ impl App {
(root_blocks_disable, profile_configured)
};
let mut permissions_history_label: Option<&'static str> = None;
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
let mut builder = ConfigEditsBuilder::for_config(&self.config)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in updates {
@@ -407,7 +408,7 @@ impl App {
},
];
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -591,7 +592,7 @@ mod tests {
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
ConfigEditsBuilder::new(&app.config.codex_home)
ConfigEditsBuilder::for_config(&app.config)
.with_edits([
ConfigEdit::SetPath {
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
+19 -19
View File
@@ -1079,7 +1079,7 @@ impl App {
}
let profile = self.active_profile.as_deref();
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
let builder = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_windows_sandbox_mode(if elevated_enabled {
"elevated"
@@ -1182,7 +1182,7 @@ impl App {
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_model(Some(model.as_str()), effort)
.apply()
@@ -1260,7 +1260,7 @@ impl App {
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_personality(Some(personality))
.apply()
@@ -1297,7 +1297,7 @@ impl App {
self.refresh_status_line();
let profile = self.active_profile.as_deref();
self.config.service_tier = service_tier.clone();
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
let mut edits = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.set_service_tier(service_tier.clone());
if service_tier.is_none() {
@@ -1335,11 +1335,11 @@ impl App {
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
ConfigEditsBuilder::for_config(&self.config)
.set_realtime_speaker(name.as_deref())
}
};
@@ -1476,7 +1476,7 @@ impl App {
} else {
vec!["approvals_reviewer".to_string()]
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_profile(profile)
.with_edits([ConfigEdit::SetPath {
segments,
@@ -1528,7 +1528,7 @@ impl App {
self.chat_widget.set_plan_mode_reasoning_effort(effort);
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_full_access_warning(/*acknowledged*/ true)
.apply()
.await
@@ -1543,7 +1543,7 @@ impl App {
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_world_writable_warning(/*acknowledged*/ true)
.apply()
.await
@@ -1558,7 +1558,7 @@ impl App {
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
.apply()
.await
@@ -1591,7 +1591,7 @@ impl App {
} else {
ConfigEdit::ClearPath { segments }
};
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
@@ -1615,7 +1615,7 @@ impl App {
from_model,
to_model,
} => {
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
@@ -1658,7 +1658,7 @@ impl App {
path: path.to_path_buf(),
enabled,
}];
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -1710,7 +1710,7 @@ impl App {
},
]
};
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits(edits)
.apply()
.await
@@ -1873,7 +1873,7 @@ impl App {
let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
let colors_edit =
crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([items_edit, colors_edit])
.apply()
.await;
@@ -1905,7 +1905,7 @@ impl App {
AppEvent::TerminalTitleSetup { items } => {
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await;
@@ -1931,7 +1931,7 @@ impl App {
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
let apply_result = ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await;
@@ -2043,7 +2043,7 @@ impl App {
let edit =
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
@@ -2088,7 +2088,7 @@ impl App {
};
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
match ConfigEditsBuilder::new(&self.config.codex_home)
match ConfigEditsBuilder::for_config(&self.config)
.with_edits([edit])
.apply()
.await
+1 -1
View File
@@ -209,7 +209,7 @@ pub(super) async fn prepare_startup_tooltip_override(
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
if let Err(err) = ConfigEditsBuilder::for_config(config)
.set_model_availability_nux_count(&updated_shown_count)
.apply()
.await
+1
View File
@@ -25,6 +25,7 @@ pub(super) async fn make_test_app() -> App {
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
+33 -28
View File
@@ -3489,35 +3489,38 @@ async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
#[tokio::test]
async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
!app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.active_thread_id, Some(side_thread_id));
assert_eq!(
Box::pin(async {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let parent_thread_id = ThreadId::new();
let side_thread_id = ThreadId::new();
app.active_thread_id = Some(side_thread_id);
app.side_threads
.get(&side_thread_id)
.map(|state| state.parent_thread_id),
Some(parent_thread_id)
);
assert!(app.agent_navigation.get(&side_thread_id).is_some());
Ok(())
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.agent_navigation.upsert(
side_thread_id,
Some("Side".to_string()),
Some("side".to_string()),
/*is_closed*/ false,
);
assert!(
!app.discard_side_thread(&mut app_server, side_thread_id)
.await
);
assert_eq!(app.active_thread_id, Some(side_thread_id));
assert_eq!(
app.side_threads
.get(&side_thread_id)
.map(|state| state.parent_thread_id),
Some(parent_thread_id)
);
assert!(app.agent_navigation.get(&side_thread_id).is_some());
Ok(())
})
.await
}
#[tokio::test]
@@ -3810,6 +3813,7 @@ async fn make_test_app() -> App {
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
@@ -3872,6 +3876,7 @@ async fn make_test_app_with_channels() -> (
active_profile: None,
cli_kv_overrides: Vec::new(),
harness_overrides: ConfigOverrides::default(),
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
runtime_approval_policy_override: None,
runtime_permission_profile_override: None,
file_search,
+6 -5
View File
@@ -2023,10 +2023,11 @@ fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String {
}
fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.get_user_layer()
.and_then(|user_layer| user_layer.config.get("marketplaces"))
let Some(user_config) = config.config_layer_stack.effective_user_config() else {
return false;
};
user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
.is_some_and(|marketplaces| marketplaces.contains_key(marketplace_name))
}
@@ -2034,7 +2035,7 @@ fn marketplace_is_user_configured(config: &Config, marketplace_name: &str) -> bo
fn marketplace_is_user_configured_git(config: &Config, marketplace_name: &str) -> bool {
config
.config_layer_stack
.get_user_layer()
.get_active_user_layer()
.and_then(|user_layer| user_layer.config.get("marketplaces"))
.and_then(toml::Value::as_table)
.and_then(|marketplaces| marketplaces.get(marketplace_name))
+5 -2
View File
@@ -385,7 +385,7 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String {
ConfigLayerSource::System { file } => {
format!("system ({})", file.as_path().display())
}
ConfigLayerSource::User { file } => {
ConfigLayerSource::User { file, .. } => {
format!("user ({})", file.as_path().display())
}
ConfigLayerSource::Project { dot_codex_folder } => {
@@ -728,7 +728,10 @@ mod tests {
};
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
ConfigLayerSource::User {
file: user_file,
profile: None,
},
empty_toml_table(),
)],
requirements,
@@ -148,7 +148,7 @@ async fn persist_external_agent_config_migration_prompt_shown(
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
@@ -221,7 +221,7 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
return Ok(());
}
ConfigEditsBuilder::new(&config.codex_home)
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
+17 -9
View File
@@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::find_codex_home;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use crate::legacy_core::format_exec_policy_error_with_source;
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
use crate::session_resume::ResolveCwdOutcome;
@@ -842,6 +843,12 @@ pub async fn run_main(
let cwd = cli.cwd.clone();
let config_cwd =
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
let mut loader_overrides = loader_overrides;
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
loader_overrides.user_config_path = Some(user_config_path);
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
#[allow(clippy::print_stderr)]
let config_toml = match load_config_as_toml_with_cli_and_load_options(
@@ -946,8 +953,8 @@ pub async fn run_main(
let mut config = load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await;
@@ -1003,8 +1010,8 @@ pub async fn run_main(
config = load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await;
@@ -1321,8 +1328,8 @@ async fn run_ratatui_app(
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await
@@ -1525,8 +1532,8 @@ async fn run_ratatui_app(
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
fallback_cwd,
)
@@ -1536,8 +1543,8 @@ async fn run_ratatui_app(
load_config_or_exit(
cli_kv_overrides.clone(),
overrides.clone(),
cloud_requirements.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
strict_config,
)
.await
@@ -1579,7 +1586,7 @@ async fn run_ratatui_app(
arg0_paths,
config.clone(),
cli_kv_overrides.clone(),
loader_overrides,
loader_overrides.clone(),
strict_config,
cloud_requirements.clone(),
feedback.clone(),
@@ -1611,6 +1618,7 @@ async fn run_ratatui_app(
config,
cli_kv_overrides.clone(),
overrides.clone(),
loader_overrides.clone(),
active_profile,
prompt,
images,
@@ -1721,15 +1729,15 @@ async fn get_login_status(
async fn load_config_or_exit(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
cloud_requirements: CloudRequirementsLoader,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
strict_config: bool,
) -> Config {
load_config_or_exit_with_fallback_cwd(
cli_kv_overrides,
overrides,
cloud_requirements,
loader_overrides,
cloud_requirements,
strict_config,
/*fallback_cwd*/ None,
)
@@ -1739,8 +1747,8 @@ async fn load_config_or_exit(
async fn load_config_or_exit_with_fallback_cwd(
cli_kv_overrides: Vec<(String, toml::Value)>,
overrides: ConfigOverrides,
cloud_requirements: CloudRequirementsLoader,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
strict_config: bool,
fallback_cwd: Option<PathBuf>,
) -> Config {
+2
View File
@@ -24,6 +24,7 @@ use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_config::LoaderOverrides;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::ModelProviderInfo;
use codex_protocol::ThreadId;
@@ -80,6 +81,7 @@ fn app_server_workspace_write_profile(network_enabled: bool) -> PermissionProfil
async fn test_config(temp_home: &TempDir) -> Config {
let mut config = ConfigBuilder::default()
.codex_home(temp_home.path().to_path_buf())
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await
.expect("load config");