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-oaiandCodex authored and GitHub committed 2026-05-14 15:16:15 +02:00
1 parent 17cd321c32
commit deedf3b2c4
55 files changed
+1302 -241

No files matched your 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,