mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
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:
1 parent
17cd321c32
commit
deedf3b2c4
55 files changed
+1302
-241
No files matched your search
@@ -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()],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user