From deedf3b2c4781b4ec2ff77fb74815803934db8ab Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 14 May 2026 15:16:15 +0200 Subject: [PATCH] feat: add layered --profile-v2 config files (#17141) ## Why `--profile-v2 ` 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/.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 ` 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 --- .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../schema/json/v2/ConfigReadResponse.json | 7 + .../schema/json/v2/ConfigWriteResponse.json | 7 + .../schema/typescript/v2/ConfigLayerSource.ts | 7 +- .../src/protocol/v2/config.rs | 12 +- .../src/config/external_agent_config.rs | 2 +- codex-rs/app-server/src/config_manager.rs | 14 ++ .../app-server/src/config_manager_service.rs | 16 +- .../src/config_manager_service_tests.rs | 82 ++++++- .../app-server/tests/suite/v2/config_rpc.rs | 18 +- codex-rs/cli/src/main.rs | 97 +++++++- codex-rs/config/src/diagnostics.rs | 2 +- codex-rs/config/src/lib.rs | 2 + codex-rs/config/src/loader/mod.rs | 82 +++++-- codex-rs/config/src/state.rs | 211 +++++++++++++----- codex-rs/config/src/state_tests.rs | 107 +++++++++ .../src/installed_marketplaces.rs | 4 +- codex-rs/core-plugins/src/loader.rs | 4 +- codex-rs/core-plugins/src/loader_tests.rs | 62 +++++ codex-rs/core-plugins/src/manager.rs | 4 +- .../core-plugins/src/marketplace_upgrade.rs | 4 +- codex-rs/core-skills/src/loader_tests.rs | 16 +- codex-rs/core-skills/src/manager_tests.rs | 25 ++- .../core/src/config/config_loader_tests.rs | 80 ++++++- codex-rs/core/src/config/config_tests.rs | 56 ++++- codex-rs/core/src/config/edit.rs | 50 ++++- codex-rs/core/src/config/mod.rs | 48 ++++ codex-rs/core/src/config_lock.rs | 1 + codex-rs/core/src/exec_policy_tests.rs | 1 + codex-rs/core/src/mcp_tool_call.rs | 53 +++-- codex-rs/core/src/mcp_tool_call_tests.rs | 16 +- codex-rs/core/src/network_proxy_loader.rs | 2 +- codex-rs/core/src/session/mod.rs | 69 ++++-- codex-rs/core/src/session/tests.rs | 65 ++++++ codex-rs/core/tests/common/hooks.rs | 2 +- .../core/tests/suite/deprecation_notice.rs | 1 + codex-rs/exec/src/lib.rs | 8 +- codex-rs/hooks/src/config_rules.rs | 4 + codex-rs/hooks/src/engine/discovery.rs | 4 +- codex-rs/hooks/src/engine/mod_tests.rs | 26 ++- codex-rs/protocol/src/config_types.rs | 80 +++++++ codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/config_persistence.rs | 7 +- codex-rs/tui/src/app/event_dispatch.rs | 38 ++-- codex-rs/tui/src/app/startup_prompts.rs | 2 +- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 61 ++--- codex-rs/tui/src/chatwidget/plugins.rs | 11 +- codex-rs/tui/src/debug_config.rs | 7 +- ...external_agent_config_migration_startup.rs | 4 +- codex-rs/tui/src/lib.rs | 26 ++- codex-rs/tui/src/status/tests.rs | 2 + codex-rs/utils/cli/src/lib.rs | 1 + codex-rs/utils/cli/src/shared_options.rs | 14 ++ 55 files changed, 1302 insertions(+), 241 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6da572813..066e1f487 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7423,6 +7423,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e338f4151..95ce7e4ae 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3812,6 +3812,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 2e0a05725..cb9176e56 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -482,6 +482,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index 631318a8b..37816463b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -84,6 +84,13 @@ ], "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." }, + "profile": { + "description": "Name of the selected profile-v2 config layered on top of the base user config, when this layer represents one.", + "type": [ + "string", + "null" + ] + }, "type": { "enum": [ "user" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts index 1fe19b676..08cb8c6bf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -13,4 +13,9 @@ file: AbsolutePathBuf, } | { "type": "user", * This is the path to the user's config.toml file, though it is not * guaranteed to exist. */ -file: AbsolutePathBuf, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" }; +file: AbsolutePathBuf, +/** + * Name of the selected profile-v2 config layered on top of the base + * user config, when this layer represents one. + */ +profile: string | null, } | { "type": "project", dotCodexFolder: AbsolutePathBuf, } | { "type": "sessionFlags" } | { "type": "legacyManagedConfigTomlFromFile", file: AbsolutePathBuf, } | { "type": "legacyManagedConfigTomlFromMdm" }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 28a2ad01f..108ce9252 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -51,6 +51,10 @@ pub enum ConfigLayerSource { /// This is the path to the user's config.toml file, though it is not /// guaranteed to exist. file: AbsolutePathBuf, + + /// Name of the selected profile-v2 config layered on top of the base + /// user config, when this layer represents one. + profile: Option, }, /// Path to a .codex/ folder within a project. There could be multiple of @@ -84,7 +88,13 @@ impl ConfigLayerSource { match self { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::User { profile, .. } => { + if profile.is_some() { + 21 + } else { + 20 + } + } ConfigLayerSource::Project { .. } => 25, ConfigLayerSource::SessionFlags => 30, ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 9de2c184b..18c276f07 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -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::>() { diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 628a4cc8e..25fdc5c0c 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -62,6 +62,10 @@ impl ConfigManager { self.codex_home.as_path() } + pub(crate) fn user_config_path(&self) -> std::io::Result { + 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) diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index aef4393a0..64c5bee68 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -196,8 +196,9 @@ impl ConfigManager { expected_version: Option, edits: Vec<(String, JsonValue, MergeStrategy)>, ) -> Result { - 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 { - 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, }; diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index e9b0b3c76..c1a081e02 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -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 + } ); } diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 406dc9ef6..2cbf476f1 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -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, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 99951d12e..38b3ea0c5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -37,6 +37,7 @@ use codex_tui::ExitReason; use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -56,11 +57,13 @@ use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; use doctor::DoctorCommand; +use codex_config::LoaderOverrides; use codex_core::build_models_manager; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; +use codex_core::config::resolve_profile_v2_config_path; use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; @@ -853,6 +856,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let root_remote_auth_token_env = remote.remote_auth_token_env; let root_strict_config = interactive.strict_config; reject_root_strict_config_for_subcommand(root_strict_config, &subcommand)?; + if let Some(subcommand) = subcommand.as_ref() { + profile_v2_for_subcommand(&interactive, subcommand)?; + } match subcommand { None => { @@ -895,6 +901,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { "review", )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; + exec_cli + .shared + .inherit_exec_root_options(&interactive.shared); exec_cli.command = Some(ExecCommand::Review(review_args)); exec_cli.strict_config = strict_config || root_strict_config; prepend_config_flags( @@ -971,7 +980,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, - codex_config::LoaderOverrides::default(), + LoaderOverrides::default(), strict_config, analytics_default_enabled, transport, @@ -1446,6 +1455,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { Ok(()) } +fn profile_v2_for_subcommand<'a>( + interactive: &'a TuiCli, + subcommand: &Subcommand, +) -> anyhow::Result> { + let Some(profile_v2) = interactive.config_profile_v2.as_ref() else { + return Ok(None); + }; + + match subcommand { + Subcommand::Exec(_) + | Subcommand::Review(_) + | Subcommand::Resume(_) + | Subcommand::Fork(_) + | Subcommand::Debug(DebugCommand { + subcommand: DebugSubcommand::PromptInput(_), + }) => Ok(Some(profile_v2)), + _ => anyhow::bail!( + "--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`." + ), + } +} + async fn run_exec_server_command( cmd: ExecServerCommand, arg0_paths: &Arg0DispatchPaths, @@ -1504,6 +1535,22 @@ async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyho Ok(()) } +fn loader_overrides_for_profile( + profile_v2: Option<&ProfileV2Name>, +) -> anyhow::Result { + match profile_v2 { + Some(profile_v2) => { + let codex_home = find_codex_home()?; + Ok(LoaderOverrides { + user_config_path: Some(resolve_profile_v2_config_path(&codex_home, profile_v2)), + user_config_profile: Some(profile_v2.clone()), + ..Default::default() + }) + } + None => Ok(LoaderOverrides::default()), + } +} + fn maybe_print_under_development_feature_warning( codex_home: &std::path::Path, interactive: &TuiCli, @@ -1546,6 +1593,7 @@ async fn run_debug_prompt_input_command( interactive: TuiCli, arg0_paths: Arg0DispatchPaths, ) -> anyhow::Result<()> { + let loader_overrides = loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; let shared = interactive.shared.into_inner(); let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -1585,6 +1633,7 @@ async fn run_debug_prompt_input_command( let config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) .build() .await?; @@ -2107,6 +2156,49 @@ mod tests { finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } + fn profile_v2_for_args(args: &[&str]) -> anyhow::Result> { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let Some(subcommand) = cli.subcommand.as_ref() else { + return Ok(cli + .interactive + .config_profile_v2 + .as_ref() + .map(std::string::ToString::to_string)); + }; + Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string)) + } + + #[test] + fn profile_v2_is_rejected_for_config_management_subcommands() { + assert!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err() + ); + } + + #[test] + fn profile_v2_is_allowed_for_runtime_subcommands() { + assert_eq!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"]) + .expect("resume supports profile-v2") + .as_deref(), + Some("work") + ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"]) + .expect("debug prompt-input supports profile-v2") + .as_deref(), + Some("work") + ); + } + + #[test] + fn profile_v2_rejects_non_plain_names_at_parse_time() { + assert!( + MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"]) + .is_err() + ); + } + #[test] fn exec_resume_last_accepts_prompt_positional() { let cli = @@ -2510,6 +2602,8 @@ mod tests { "gpt-5.1-test", "-p", "my-profile", + "--profile-v2", + "my-config", "-C", "/tmp", "--strict-config", @@ -2522,6 +2616,7 @@ mod tests { assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); assert!(interactive.oss); assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); + assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config")); assert_matches!( interactive.sandbox_mode, Some(codex_utils_cli::SandboxModeCliArg::WorkspaceWrite) diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 69549dc20..3fc593dcd 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -229,7 +229,7 @@ where fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option { match &layer.name { ConfigLayerSource::System { file } => Some(file.to_path_buf()), - ConfigLayerSource::User { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file, .. } => Some(file.to_path_buf()), ConfigLayerSource::Project { dot_codex_folder } => { Some(dot_codex_folder.as_path().join(config_toml_file)) } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e48dcedac..371c3ffba 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -32,6 +32,8 @@ pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; +pub use codex_protocol::config_types::ProfileV2Name; +pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_utils_absolute_path::AbsolutePathBuf; pub use config_requirements::AppRequirementToml; pub use config_requirements::AppToolRequirementToml; diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 1bf70c4d0..37cb41b3e 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -4,6 +4,7 @@ mod macos; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; +use crate::ProfileV2Name; use crate::cloud_requirements::CloudRequirementsLoader; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; @@ -89,6 +90,7 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) /// - user `${CODEX_HOME}/config.toml` +/// - profile `${CODEX_HOME}/.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) @@ -116,6 +118,7 @@ pub async fn load_config_layers_state( loader_overrides: overrides, strict_config, } = options.into(); + let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = @@ -205,29 +208,34 @@ 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()), + // 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, + /*profile*/ None, + ignore_user_config, + strict_config, ) - } else { - load_config_toml_for_required_layer(fs, &user_file, strict_config, |config_toml| { - ConfigLayerEntry::new( - ConfigLayerSource::User { - file: user_file.clone(), - }, - config_toml, + .await?, + ); + + let active_user_file = overrides.user_config_path(codex_home)?; + if active_user_file != base_user_file { + layers.push( + load_user_config_layer( + fs, + &active_user_file, + active_user_profile.as_ref(), + ignore_user_config, + strict_config, ) - }) - .await? - }; - layers.push(user_layer); + .await?, + ); + } let mut startup_warnings = None; if let Some(cwd) = cwd { @@ -258,7 +266,7 @@ pub async fn load_config_layers_state( &cwd, &project_root_markers, codex_home, - &user_file, + &active_user_file, ) .await { @@ -356,6 +364,36 @@ pub async fn load_config_layers_state( }) } +async fn load_user_config_layer( + fs: &dyn ExecutorFileSystem, + user_file: &AbsolutePathBuf, + profile: Option<&ProfileV2Name>, + ignore_user_config: bool, + strict_config: bool, +) -> io::Result { + let profile = profile.map(ToString::to_string); + if ignore_user_config { + return Ok(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile, + }, + TomlValue::Table(toml::map::Map::new()), + )); + } + + load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + profile: profile.clone(), + }, + config_toml, + ) + }) + .await +} + fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigLayerEntry) { match layers .iter() diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 015cd5d23..3ec0bfb31 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -5,12 +5,14 @@ use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; use super::key_aliases::normalized_with_key_aliases; use super::merge::merge_toml_values; +use crate::ProfileV2Name; use codex_app_server_protocol::ConfigLayer; use codex_app_server_protocol::ConfigLayerMetadata; use codex_app_server_protocol::ConfigLayerSource; use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value as JsonValue; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use toml::Value as TomlValue; @@ -33,6 +35,8 @@ impl From for ConfigLoadOptions { /// LoaderOverrides overrides managed configuration inputs (primarily for tests). #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { + pub user_config_path: Option, + pub user_config_profile: Option, pub managed_config_path: Option, pub system_config_path: Option, pub system_requirements_path: Option, @@ -52,6 +56,8 @@ impl LoaderOverrides { pub fn without_managed_config_for_tests() -> Self { let base = std::env::temp_dir().join("codex-config-tests"); Self { + user_config_path: None, + user_config_profile: None, managed_config_path: Some(base.join("managed_config.toml")), system_config_path: Some(base.join("config.toml")), system_requirements_path: Some(base.join("requirements.toml")), @@ -69,10 +75,22 @@ 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, + user_config_profile: None, managed_config_path: Some(managed_config_path), ..Self::without_managed_config_for_tests() } } + + pub fn user_config_path(&self, codex_home: &Path) -> std::io::Result { + match self.user_config_path.as_ref() { + Some(path) => Ok(path.clone()), + None => Ok(AbsolutePathBuf::resolve_path_against_base( + crate::CONFIG_TOML_FILE, + codex_home, + )), + } + } } #[derive(Debug, Clone, PartialEq)] @@ -163,7 +181,7 @@ impl ConfigLayerEntry { match &self.name { ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), - ConfigLayerSource::User { file } => file.parent(), + ConfigLayerSource::User { file, .. } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None, ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => None, @@ -196,7 +214,12 @@ pub struct ConfigLayerStack { /// later entries in the Vec override earlier ones. layers: Vec, - /// 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, /// Constraints that must be enforced when deriving a [Config] from the @@ -256,14 +279,61 @@ impl ConfigLayerStack { self.startup_warnings.as_deref() } - /// 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> { + /// This does not merge other config layers or apply any requirements. When + /// a profile-v2 layer is active, this returns that profile layer rather than + /// the base `$CODEX_HOME/config.toml` layer because the active layer is the + /// writable target for profile-aware edits. + pub fn get_active_user_layer(&self) -> Option<&ConfigLayerEntry> { self.user_layer_index .and_then(|index| self.layers.get(index)) } + pub fn get_user_config_file(&self) -> Option<&AbsolutePathBuf> { + let layer = self.get_active_user_layer()?; + let ConfigLayerSource::User { file, .. } = &layer.name else { + return None; + }; + Some(file) + } + + /// Returns all user config layers in the requested precedence order. + /// + /// With profile-v2 enabled, `LowestPrecedenceFirst` returns the base user + /// config before the profile overlay, while `HighestPrecedenceFirst` returns + /// the profile overlay before the base user config. + 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 { + 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 } @@ -272,54 +342,101 @@ impl ConfigLayerStack { &self.requirements_toml } - /// Creates a new [ConfigLayerStack] using the specified values to inject a - /// "user layer" into the stack. If such a layer already exists, it is - /// replaced; otherwise, it is inserted into the stack at the appropriate - /// position based on precedence rules. + /// Creates a new [ConfigLayerStack] using the specified values to inject one + /// user layer into the stack. If such a layer already exists, it is replaced; + /// otherwise, it is inserted into the stack at the appropriate position + /// based on precedence rules. When the stack has both base and profile-v2 + /// user layers, this updates only the layer whose file matches + /// `config_toml`. pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self { - self.with_user_layer(Some(ConfigLayerEntry::new( + let profile = self.layers.iter().find_map(|layer| match &layer.name { + ConfigLayerSource::User { file, profile } if file == config_toml => profile + .as_deref() + .and_then(|profile| profile.parse::().ok()), + _ => None, + }); + self.with_user_config_profile(config_toml, profile.as_ref(), user_config) + } + + pub fn with_user_config_profile( + &self, + config_toml: &AbsolutePathBuf, + profile: Option<&ProfileV2Name>, + user_config: TomlValue, + ) -> Self { + let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: config_toml.clone(), + profile: profile.map(ToString::to_string), }, user_config, - ))) + ); + + let mut layers = self.layers.clone(); + if let Some(index) = layers.iter().position(|layer| { + matches!( + &layer.name, + ConfigLayerSource::User { file, .. } if file == config_toml + ) + }) { + layers.remove(index); + } + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), + } + let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| { + if matches!(layer.name, ConfigLayerSource::User { .. }) { + Some(index) + } else { + None + } + }); + Self { + layers, + user_layer_index, + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), + } } /// Returns a new stack with the user layer copied from `other`, preserving /// every non-user layer already present in this stack. pub fn with_user_layer_from(&self, other: &Self) -> Self { - self.with_user_layer(other.get_user_layer().cloned()) - } - - fn with_user_layer(&self, user_layer: Option) -> Self { - let mut layers = self.layers.clone(); - let user_layer_index = match (self.user_layer_index, user_layer) { - (Some(index), Some(user_layer)) => { - layers[index] = user_layer; - Some(index) + let user_layers = other + .layers + .iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + let mut layers = self + .layers + .iter() + .filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. })) + .cloned() + .collect::>(); + for user_layer in user_layers { + match layers + .iter() + .position(|layer| layer.name.precedence() > user_layer.name.precedence()) + { + Some(index) => layers.insert(index, user_layer), + None => layers.push(user_layer), } - (Some(index), None) => { - layers.remove(index); + } + let user_layer_index = layers.iter().enumerate().rev().find_map(|(index, layer)| { + if matches!(layer.name, ConfigLayerSource::User { .. }) { + Some(index) + } else { None } - (None, Some(user_layer)) => { - let user_layer_index = match layers - .iter() - .position(|layer| layer.name.precedence() > user_layer.name.precedence()) - { - Some(index) => { - layers.insert(index, user_layer); - index - } - None => { - layers.push(user_layer); - layers.len() - 1 - } - }; - Some(user_layer_index) - } - (None, None) => None, - }; + }); Self { layers, user_layer_index, @@ -395,7 +512,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> { if !layers.iter().map(|layer| &layer.name).is_sorted() { return Err(std::io::Error::new( @@ -405,19 +522,13 @@ fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result = 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); } diff --git a/codex-rs/config/src/state_tests.rs b/codex-rs/config/src/state_tests.rs index 81e1fa63f..fb6e26968 100644 --- a/codex-rs/config/src/state_tests.rs +++ b/codex-rs/config/src/state_tests.rs @@ -1,5 +1,11 @@ use super::*; use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn test_user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name)) + .expect("test user config path should be absolute") +} #[test] fn origins_use_canonical_key_aliases() { @@ -32,3 +38,104 @@ 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 temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file, + profile: None, + }, + toml::from_str( + r#" +model = "base" +approval_policy = "on-failure" +"#, + ) + .expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + 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") + ); +} + +#[test] +fn with_user_config_updates_matching_user_layer_without_replacing_active_profile() { + let temp_dir = TempDir::new().expect("tempdir"); + let base_file = test_user_config_path(&temp_dir, "config.toml"); + let profile_file = test_user_config_path(&temp_dir, "work.config.toml"); + let base_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: base_file.clone(), + profile: None, + }, + toml::from_str(r#"model = "base""#).expect("base config"), + ); + let profile_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: profile_file.clone(), + profile: Some("work".to_string()), + }, + toml::from_str(r#"approval_policy = "on-failure""#).expect("profile config"), + ); + let stack = ConfigLayerStack::new( + vec![base_layer, profile_layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("multiple user layers should be valid"); + + let updated = stack.with_user_config( + &base_file, + toml::from_str(r#"model = "updated-base""#).expect("updated base config"), + ); + + assert_eq!(updated.get_user_config_file(), Some(&profile_file)); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("model") + .and_then(toml::Value::as_str), + Some("updated-base") + ); + assert_eq!( + updated + .effective_user_config() + .expect("merged user config") + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("on-failure") + ); +} diff --git a/codex-rs/core-plugins/src/installed_marketplaces.rs b/codex-rs/core-plugins/src/installed_marketplaces.rs index f313a0627..0377fac9e 100644 --- a/codex-rs/core-plugins/src/installed_marketplaces.rs +++ b/codex-rs/core-plugins/src/installed_marketplaces.rs @@ -18,10 +18,10 @@ pub fn installed_marketplace_roots_from_layer_stack( config_layer_stack: &ConfigLayerStack, codex_home: &Path, ) -> Vec { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return Vec::new(); }; - let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + let Some(marketplaces_value) = user_config.get("marketplaces") else { return Vec::new(); }; let Some(marketplaces) = marketplaces_value.as_table() else { diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f348a3414..b26f1c21b 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -378,10 +378,10 @@ fn refresh_non_curated_plugin_cache_with_mode( fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return HashMap::new(); }; - configured_plugins_from_user_config_value(&user_layer.config) + configured_plugins_from_user_config_value(&user_config) } fn is_full_git_sha(value: &str) -> bool { diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index d9029c584..a360d54cc 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -1,7 +1,69 @@ use super::*; use crate::manifest::load_plugin_manifest; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerSource; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; use codex_plugin::PluginId; use pretty_assertions::assert_eq; +use tempfile::TempDir; + +fn user_config_path(temp_dir: &TempDir, file_name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name)) + .expect("test user config path should be absolute") +} + +fn user_layer(path: AbsolutePathBuf, config: &str) -> ConfigLayerEntry { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: path, + profile: None, + }, + toml::from_str(config).expect("user config toml"), + ) +} + +#[test] +fn configured_plugins_from_stack_merges_user_layers() { + let temp_dir = TempDir::new().expect("tempdir"); + let stack = ConfigLayerStack::new( + vec![ + user_layer( + user_config_path(&temp_dir, "config.toml"), + "[plugins.base]\nenabled = true\n", + ), + user_layer( + user_config_path(&temp_dir, "work.config.toml"), + "[plugins.profile]\nenabled = false\n", + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"); + + let plugins = configured_plugins_from_stack(&stack); + + assert_eq!( + plugins, + HashMap::from([ + ( + "base".to_string(), + PluginConfig { + enabled: true, + mcp_servers: HashMap::new(), + }, + ), + ( + "profile".to_string(), + PluginConfig { + enabled: false, + mcp_servers: HashMap::new(), + }, + ), + ]) + ); +} #[test] fn plugin_mcp_file_supports_mcp_servers_object_format() { diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 717aff24f..f6d5a6d88 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1997,10 +1997,10 @@ pub(crate) fn configured_plugins_from_stack( config_layer_stack: &ConfigLayerStack, ) -> HashMap { // Plugin entries remain persisted user config only. - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return HashMap::new(); }; - configured_plugins_from_user_config_value(&user_layer.config) + configured_plugins_from_user_config_value(&user_config) } fn configured_plugins_from_user_config_value( diff --git a/codex-rs/core-plugins/src/marketplace_upgrade.rs b/codex-rs/core-plugins/src/marketplace_upgrade.rs index 81474cf66..9dda8a9a3 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade.rs @@ -108,10 +108,10 @@ fn marketplace_install_root(codex_home: &Path) -> PathBuf { fn configured_git_marketplaces( config_layer_stack: &ConfigLayerStack, ) -> Vec { - let Some(user_layer) = config_layer_stack.get_user_layer() else { + let Some(user_config) = config_layer_stack.effective_user_config() else { return Vec::new(); }; - let Some(marketplaces_value) = user_layer.config.get("marketplaces") else { + let Some(marketplaces_value) = user_config.get("marketplaces") else { return Vec::new(); }; let marketplaces = match marketplaces_value diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index c80585871..65a0b5a4f 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -99,6 +99,7 @@ async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig { ConfigLayerEntry::new( ConfigLayerSource::User { file: config_file(user_config_path), + profile: None, }, TomlValue::Table(toml::map::Map::new()), ), @@ -164,7 +165,10 @@ async fn skill_roots_from_layer_stack_maps_user_to_user_and_system_cache_and_sys TomlValue::Table(toml::map::Map::new()), ), ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), ), ]; @@ -222,7 +226,10 @@ async fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyh let layers = vec![ ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), ), ConfigLayerEntry::new_disabled( @@ -281,7 +288,10 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() let user_file = user_folder.join("config.toml").abs(); let layers = vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, TomlValue::Table(toml::map::Map::new()), )]; let stack = ConfigLayerStack::new( diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 338da0c39..15b06b393 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -87,7 +87,10 @@ fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntr let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)) .expect("user config path should be absolute"); ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, toml::from_str(config_toml).expect("user layer toml"), ) } @@ -495,7 +498,10 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) .expect("user layer toml"), ); @@ -527,7 +533,10 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill( let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ true)) .expect("user layer toml"), ); @@ -562,7 +571,10 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&name_toggle_config("github:yeet", /*enabled*/ false)) .expect("user layer toml"), ); @@ -592,7 +604,10 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml")) .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( - ConfigLayerSource::User { file: user_file }, + ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::from_str(&path_toggle_config(&skill_path, /*enabled*/ false)) .expect("user layer toml"), ); diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 136a42284..b5c5c2ec1 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -184,7 +184,7 @@ invalid = ["#, .await?; let user_layer = layers - .get_user_layer() + .get_active_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml is ignored"); assert_eq!( user_layer.config, @@ -329,7 +329,7 @@ command = "python3 /tmp/user-hook.py" assert!( layers - .get_user_layer() + .get_active_user_layer() .and_then(|layer| layer.config.get("hooks")) .is_some(), "hooks should still deserialize from config.toml" @@ -572,11 +572,12 @@ async fn returns_empty_when_all_layers_missing() { .await .expect("load layers"); let user_layer = layers - .get_user_layer() + .get_active_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml does not exist"); let expected_user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + profile: None, }, TomlValue::Table(toml::map::Map::new()), ); @@ -614,6 +615,78 @@ 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(AbsolutePathBuf::from_absolute_path(&selected_config).expect("selected config path")); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + 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, + ) + .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, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(tmp.path().join(CONFIG_TOML_FILE)) + .expect("base user config path"), + profile: None, + } + ); + let user_layer = layers.get_active_user_layer().expect("selected user layer"); + assert_eq!( + user_layer.name, + ConfigLayerSource::User { + file: AbsolutePathBuf::from_absolute_path(&selected_config) + .expect("selected user config path"), + profile: Some("work".to_string()), + } + ); + 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()?; @@ -653,6 +726,7 @@ async fn includes_thread_config_layers_in_stack() -> anyhow::Result<()> { ConfigLayerSource::SessionFlags, ConfigLayerSource::User { file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + profile: None, }, ConfigLayerSource::System { file: expected_system_config, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e0be0bda6..59502e4fb 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7,6 +7,7 @@ use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerEntry; +use codex_config::ProfileV2Name; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -3334,6 +3335,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io:: ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] @@ -3388,6 +3390,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io:: ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [mcp_servers.session_overrides_user] @@ -3518,6 +3521,7 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() vec![ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: user_file.clone(), + profile: None, }, toml::toml! { [features] @@ -3544,7 +3548,10 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() .await?; let thread_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file, + profile: None, + }, toml::toml! { [features] plugins = false @@ -5489,6 +5496,52 @@ async fn set_model_updates_defaults() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn for_config_writes_selected_user_config_file() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let base_config = codex_home.path().join(CONFIG_TOML_FILE); + let selected_config = codex_home.path().join("work.config.toml"); + tokio::fs::write(&base_config, r#"model_provider = "openai""#).await?; + tokio::fs::write(&selected_config, r#"model = "gpt-old""#).await?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(selected_config.abs()), + user_config_profile: Some("work".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await?; + + ConfigEditsBuilder::for_config(&config) + .set_model(Some("gpt-new"), Some(ReasoningEffort::High)) + .apply() + .await?; + + let selected_serialized = tokio::fs::read_to_string(&selected_config).await?; + let selected: ConfigToml = toml::from_str(&selected_serialized)?; + assert_eq!(selected.model.as_deref(), Some("gpt-new")); + assert_eq!(selected.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + tokio::fs::read_to_string(&base_config).await?, + r#"model_provider = "openai""# + ); + + Ok(()) +} + +#[test] +fn profile_v2_config_path_resolves_validated_names() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let profile_name: ProfileV2Name = "work".parse()?; + assert_eq!( + resolve_profile_v2_config_path(codex_home.path(), &profile_name), + codex_home.path().join("work.config.toml").abs() + ); + Ok(()) +} + #[tokio::test] async fn set_model_overwrites_existing_model() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6080,6 +6133,7 @@ config_file = "./agents/researcher.toml" vec![codex_config::ConfigLayerEntry::new( codex_app_server_protocol::ConfigLayerSource::User { file: codex_home.path().join(CONFIG_TOML_FILE).abs(), + profile: None, }, layer_config, )], diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 4dde2c0cb..4e5b46e18 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1037,13 +1037,21 @@ pub fn apply_blocking( codex_home: &Path, profile: Option<&str>, edits: &[ConfigEdit], +) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + apply_blocking_to_resolved_file(&config_path, profile, edits) +} + +fn apply_blocking_to_resolved_file( + resolved_config_file: &Path, + legacy_profile: Option<&str>, + edits: &[ConfigEdit], ) -> anyhow::Result<()> { if edits.is_empty() { return Ok(()); } - let config_path = codex_home.join(CONFIG_TOML_FILE); - let write_paths = resolve_symlink_write_paths(&config_path)?; + let write_paths = resolve_symlink_write_paths(resolved_config_file)?; let serialized = match write_paths.read_path { Some(path) => match std::fs::read_to_string(&path) { Ok(contents) => contents, @@ -1059,7 +1067,7 @@ pub fn apply_blocking( serialized.parse::()? }; - let profile = profile.map(ToOwned::to_owned).or_else(|| { + let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| { doc.get("profile") .and_then(|item| item.as_str()) .map(ToOwned::to_owned) @@ -1078,7 +1086,7 @@ pub fn apply_blocking( write_atomically(&write_paths.write_path, &document.doc.to_string()).with_context(|| { format!( - "failed to persist config.toml at {}", + "failed to persist config at {}", write_paths.write_path.display() ) })?; @@ -1087,30 +1095,50 @@ pub fn apply_blocking( } /// Persist edits asynchronously by offloading the blocking writer. +/// +/// `profile` selects a legacy `[profiles.]` section inside +/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target +/// file before constructing a [ConfigEditsBuilder]. pub async fn apply( codex_home: &Path, profile: Option<&str>, edits: Vec, ) -> anyhow::Result<()> { let codex_home = codex_home.to_path_buf(); + let config_path = codex_home.join(CONFIG_TOML_FILE); let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || apply_blocking(&codex_home, profile.as_deref(), &edits)) - .await - .context("config persistence task panicked")? + task::spawn_blocking(move || { + apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits) + }) + .await + .context("config persistence task panicked")? } /// Fluent builder to batch config edits and apply them atomically. #[derive(Default)] pub struct ConfigEditsBuilder { - codex_home: PathBuf, + config_path: PathBuf, profile: Option, edits: Vec, } impl ConfigEditsBuilder { pub fn new(codex_home: &Path) -> Self { + Self::for_config_path(&codex_home.join(CONFIG_TOML_FILE)) + } + + pub fn for_config(config: &crate::config::Config) -> Self { + let config_path = config + .config_layer_stack + .get_user_config_file() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .unwrap_or_else(|| config.codex_home.join(CONFIG_TOML_FILE).to_path_buf()); + Self::for_config_path(&config_path) + } + + pub fn for_config_path(config_path: &Path) -> Self { Self { - codex_home: codex_home.to_path_buf(), + config_path: config_path.to_path_buf(), profile: None, edits: Vec::new(), } @@ -1369,13 +1397,13 @@ impl ConfigEditsBuilder { /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) } /// Apply edits asynchronously via a blocking offload. pub async fn apply(self) -> anyhow::Result<()> { task::spawn_blocking(move || { - apply_blocking(&self.codex_home, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) }) .await .context("config persistence task panicked")? diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2046d50a9..56b3b5d72 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -18,6 +18,7 @@ use codex_config::FeatureRequirementsToml; use codex_config::McpServerIdentity; use codex_config::McpServerRequirement; use codex_config::PluginRequirementsToml; +use codex_config::ProfileV2Name; use codex_config::ResidencyRequirement; use codex_config::SandboxModeRequirement; use codex_config::Sourced; @@ -187,6 +188,7 @@ pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option = None; const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0"; pub const CONFIG_TOML_FILE: &str = "config.toml"; +const CONFIG_PROFILE_V2_SUFFIX: &str = ".config.toml"; fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; @@ -1272,6 +1274,52 @@ impl Config { ) .await } + /// This is a secondary way of creating [Config], which is appropriate when + /// the harness is meant to be used with a specific configuration that + /// ignores user settings. For example, the `codex exec` subcommand is + /// designed to use [AskForApproval::Never] exclusively. + /// + /// Further, [ConfigOverrides] contains some options that are not supported + /// in [ConfigToml], such as `cwd`, `codex_self_exe`, `codex_linux_sandbox_exe`, and + /// `main_execve_wrapper_exe`. + pub async fn load_with_cli_overrides_and_harness_overrides( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + ) -> std::io::Result { + ConfigBuilder::default() + .cli_overrides(cli_overrides) + .harness_overrides(harness_overrides) + .build() + .await + } +} + +pub fn resolve_profile_v2_config_path( + codex_home: &Path, + profile_name: &ProfileV2Name, +) -> AbsolutePathBuf { + AbsolutePathBuf::resolve_path_against_base( + format!("{profile_name}{CONFIG_PROFILE_V2_SUFFIX}"), + codex_home, + ) +} + +/// 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. +pub async fn load_config_as_toml_with_cli_overrides( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, +) -> std::io::Result { + load_config_as_toml_with_cli_and_loader_overrides( + codex_home, + cwd, + cli_overrides, + loader_overrides, + ) + .await } /// DEPRECATED for most callers: prefer [Config::load_with_cli_overrides()] or diff --git a/codex-rs/core/src/config_lock.rs b/codex-rs/core/src/config_lock.rs index ff8f1e761..14ee7e110 100644 --- a/codex-rs/core/src/config_lock.rs +++ b/codex-rs/core/src/config_lock.rs @@ -84,6 +84,7 @@ pub(crate) fn lock_layer_from_config( Ok(ConfigLayerEntry::new( ConfigLayerSource::User { file: lock_path.clone(), + profile: None, }, value, )) diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 0bcc3b047..8c424c541 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -612,6 +612,7 @@ async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_config_toml, + profile: None, }, TomlValue::Table(Default::default()), ), diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index d1faa421d..fc8ce4d8c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -15,7 +15,6 @@ use crate::arc_monitor::monitor_action; use crate::config::Config; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; -use crate::config::load_global_mcp_servers; use crate::connectors; use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianMcpAnnotations; @@ -2004,8 +2003,7 @@ async fn maybe_persist_mcp_tool_approval( remember_mcp_tool_approval(sess, key).await; return; }; - persist_codex_app_tool_approval(&turn_context.config.codex_home, &connector_id, &tool_name) - .await + persist_codex_app_tool_approval(&turn_context.config, &connector_id, &tool_name).await } else { persist_non_app_mcp_tool_approval(sess, &turn_context.config, &key.server, &tool_name).await }; @@ -2026,11 +2024,11 @@ async fn maybe_persist_mcp_tool_approval( } async fn persist_codex_app_tool_approval( - codex_home: &AbsolutePathBuf, + config: &Config, connector_id: &str, tool_name: &str, ) -> anyhow::Result<()> { - ConfigEditsBuilder::new(codex_home) + ConfigEditsBuilder::for_config(config) .with_edits([ConfigEdit::SetPath { segments: vec![ "apps".to_string(), @@ -2051,11 +2049,12 @@ async fn persist_custom_mcp_tool_approval( server: &str, tool_name: &str, ) -> anyhow::Result<()> { - let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? else { + let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)? + else { anyhow::bail!("MCP server `{server}` is not configured in config.toml"); }; - persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await + persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name).await } async fn persist_non_app_mcp_tool_approval( @@ -2064,8 +2063,9 @@ async fn persist_non_app_mcp_tool_approval( server: &str, tool_name: &str, ) -> anyhow::Result<()> { - if let Some(config_folder) = custom_mcp_tool_approval_config_folder(config, server).await? { - return persist_custom_mcp_tool_approval_at(&config_folder, server, tool_name).await; + if let Some(config_edits_builder) = custom_mcp_tool_approval_config_builder(config, server)? { + return persist_custom_mcp_tool_approval_with(config_edits_builder, server, tool_name) + .await; } let plugin_config_name = sess @@ -2080,7 +2080,7 @@ async fn persist_non_app_mcp_tool_approval( .map(|plugin| plugin.config_name.clone()); if let Some(plugin_config_name) = plugin_config_name { - return ConfigEditsBuilder::new(&config.codex_home) + return ConfigEditsBuilder::for_config(config) .with_edits([ConfigEdit::SetPath { segments: vec![ "plugins".to_string(), @@ -2100,26 +2100,24 @@ async fn persist_non_app_mcp_tool_approval( anyhow::bail!("MCP server `{server}` is not configured in config.toml or an enabled plugin") } -async fn custom_mcp_tool_approval_config_folder( +fn custom_mcp_tool_approval_config_builder( config: &Config, server: &str, -) -> anyhow::Result> { +) -> anyhow::Result> { if let Some(project_config_folder) = project_mcp_tool_approval_config_folder(config, server) { - return Ok(Some(project_config_folder)); + return Ok(Some(ConfigEditsBuilder::new(&project_config_folder))); } - let servers = load_global_mcp_servers(&config.codex_home).await?; - Ok(servers - .contains_key(server) - .then(|| config.codex_home.clone())) + Ok(user_mcp_server_is_configured(config, server)? + .then(|| ConfigEditsBuilder::for_config(config))) } -async fn persist_custom_mcp_tool_approval_at( - config_folder: &AbsolutePathBuf, +async fn persist_custom_mcp_tool_approval_with( + config_edits_builder: ConfigEditsBuilder, server: &str, tool_name: &str, ) -> anyhow::Result<()> { - ConfigEditsBuilder::new(config_folder) + config_edits_builder .with_edits([ConfigEdit::SetPath { segments: vec![ "mcp_servers".to_string(), @@ -2134,6 +2132,21 @@ async fn persist_custom_mcp_tool_approval_at( .await } +fn user_mcp_server_is_configured(config: &Config, server: &str) -> anyhow::Result { + let Some(mcp_servers_toml) = config + .config_layer_stack + .effective_user_config() + .as_ref() + .and_then(|user_config| user_config.get("mcp_servers")) + .cloned() + else { + return Ok(false); + }; + let servers = + HashMap::::deserialize(mcp_servers_toml)?; + Ok(servers.contains_key(server)) +} + fn project_mcp_tool_approval_config_folder( config: &Config, server: &str, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 714b0b99e..d8b6326cd 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -30,7 +30,6 @@ use codex_rollout_trace::ToolDispatchInvocation; use codex_rollout_trace::ToolDispatchPayload; use codex_rollout_trace::ToolDispatchRequester; use codex_rollout_trace::replay_bundle; -use core_test_support::PathExt; use core_test_support::hooks::trusted_config_layer_stack; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -1874,8 +1873,13 @@ fn accepted_elicitation_without_content_defaults_to_accept() { #[tokio::test] async fn persist_codex_app_tool_approval_writes_tool_override() { let tmp = tempdir().expect("tempdir"); + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .build() + .await + .expect("load config"); - persist_codex_app_tool_approval(&tmp.path().abs(), "calendar", "calendar/list_events") + persist_codex_app_tool_approval(&config, "calendar", "calendar/list_events") .await .expect("persist approval"); @@ -2116,7 +2120,7 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() { #[tokio::test] async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_server() { - let (session, turn_context) = make_session_and_context().await; + let (session, mut turn_context) = make_session_and_context().await; let codex_home = session.codex_home().await; std::fs::create_dir_all(&codex_home).expect("create codex home"); std::fs::write( @@ -2124,6 +2128,12 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve "[mcp_servers.docs]\ncommand = \"docs-server\"\n", ) .expect("seed config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.clone().to_path_buf()) + .build() + .await + .expect("load config"); + turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), connector_id: None, diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 41ef46e3f..1c49be2da 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -90,7 +90,7 @@ fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { .filter_map(|layer| { let path = match &layer.name { ConfigLayerSource::System { file } => Some(file.clone()), - ConfigLayerSource::User { file } => Some(file.clone()), + ConfigLayerSource::User { file, .. } => Some(file.clone()), ConfigLayerSource::Project { dot_codex_folder } => { Some(dot_codex_folder.join(CONFIG_TOML_FILE)) } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fd3f31771..0a29e9788 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -179,6 +179,8 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::thread_rollout_truncation::initial_history_has_prior_user_turns; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStackOrdering; use codex_config::types::McpServerConfig; use codex_model_provider_info::ModelProviderInfo; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -1479,37 +1481,62 @@ impl Session { // // Prefer `refresh_runtime_config()` when the host can already provide a materialized // config snapshot. This file-based path exists for legacy local reload flows. - let config_toml_path = { + let config_toml_paths = { let state = self.state.lock().await; - state - .session_configuration - .codex_home - .join(CONFIG_TOML_FILE) + let config = &state.session_configuration.original_config_do_not_use; + let user_config_paths = config + .config_layer_stack + .get_user_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter_map(|layer| match &layer.name { + ConfigLayerSource::User { file, .. } => Some(file.clone()), + _ => None, + }) + .collect::>(); + if user_config_paths.is_empty() { + vec![ + state + .session_configuration + .codex_home + .join(CONFIG_TOML_FILE), + ] + } else { + user_config_paths + } }; - let user_config = match std::fs::read_to_string(&config_toml_path) { - Ok(contents) => match toml::from_str::(&contents) { - Ok(config) => config, + let mut reloaded_user_configs = Vec::with_capacity(config_toml_paths.len()); + for config_toml_path in config_toml_paths { + let user_config = match std::fs::read_to_string(&config_toml_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(config) => config, + Err(err) => { + warn!("failed to parse user config while reloading layer: {err}"); + return; + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + toml::Value::Table(Default::default()) + } Err(err) => { - warn!("failed to parse user config while reloading layer: {err}"); + warn!("failed to read user config while reloading layer: {err}"); return; } - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - toml::Value::Table(Default::default()) - } - Err(err) => { - warn!("failed to read user config while reloading layer: {err}"); - return; - } - }; + }; + reloaded_user_configs.push((config_toml_path, user_config)); + } let next_config = { let state = self.state.lock().await; let mut config = (*state.session_configuration.original_config_do_not_use).clone(); - config.config_layer_stack = config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); + for (config_toml_path, user_config) in reloaded_user_configs { + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&config_toml_path, user_config); + } config.tool_suggest = resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack); config diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a53a1e0fa..e06dba9b6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -12,6 +12,7 @@ use crate::test_support::models_manager_with_provider; use crate::tools::format_exec_output_str; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; +use codex_config::LoaderOverrides; use codex_config::NetworkConstraints; use codex_config::NetworkDomainPermissionToml; use codex_config::NetworkDomainPermissionsToml; @@ -1210,6 +1211,70 @@ async fn reload_user_config_layer_updates_effective_apps_config() { assert_eq!(app.destructive_enabled, Some(false)); } +#[tokio::test] +async fn reload_user_config_layer_updates_base_and_selected_profile_layers() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let base_config_path = codex_home.join(CONFIG_TOML_FILE); + let profile_config_path = codex_home.join("work.config.toml"); + std::fs::write( + &base_config_path, + "model = \"base\"\napproval_policy = \"on-failure\"\n", + ) + .expect("write base user config"); + std::fs::write(&profile_config_path, "model = \"profile-old\"\n") + .expect("write profile user config"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.to_path_buf()) + .loader_overrides(LoaderOverrides { + user_config_path: Some(profile_config_path.abs()), + user_config_profile: Some("work".parse().expect("profile-v2 name")), + ..LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await + .expect("load profile config"); + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + std::fs::write( + &base_config_path, + "model = \"base\"\napproval_policy = \"never\"\n", + ) + .expect("update base user config"); + std::fs::write(&profile_config_path, "model = \"profile-new\"\n") + .expect("update profile user config"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + assert_eq!( + config + .config_layer_stack + .get_user_config_file() + .map(codex_utils_absolute_path::AbsolutePathBuf::as_path), + Some(profile_config_path.as_path()) + ); + let effective_user_config = config + .config_layer_stack + .effective_user_config() + .expect("merged user config"); + assert_eq!( + effective_user_config + .get("model") + .and_then(toml::Value::as_str), + Some("profile-new") + ); + assert_eq!( + effective_user_config + .get("approval_policy") + .and_then(toml::Value::as_str), + Some("never") + ); +} + #[tokio::test] async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { let session = make_session_with_config(|config| { diff --git a/codex-rs/core/tests/common/hooks.rs b/codex-rs/core/tests/common/hooks.rs index 239041a22..d549e04b2 100644 --- a/codex-rs/core/tests/common/hooks.rs +++ b/codex-rs/core/tests/common/hooks.rs @@ -34,7 +34,7 @@ pub fn trusted_config_layer_stack( hooks: Vec, ) -> ConfigLayerStack { let mut user_config = config_layer_stack - .get_user_layer() + .get_active_user_layer() .map(|layer| layer.config.clone()) .unwrap_or_else(|| TomlValue::Table(Default::default())); let Some(user_table) = user_config.as_table_mut() else { diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 0ef7ddc33..36b5fe9d5 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -75,6 +75,7 @@ async fn emits_deprecation_notice_for_experimental_instructions_file() -> anyhow let config_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: test_absolute_path("/tmp/config.toml"), + profile: None, }, TomlValue::Table(table), ); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 23f87685c..2485a9752 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -65,6 +65,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_and_load_options; use codex_core::config::resolve_oss_provider; +use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; use codex_core::format_exec_policy_error_with_source; use codex_core::path_utils; @@ -264,6 +265,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result oss, oss_provider, config_profile, + config_profile_v2, sandbox_mode: sandbox_mode_cli_arg, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -319,8 +321,12 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result std::process::exit(1); } }; - + let user_config_path = config_profile_v2 + .as_ref() + .map(|profile_v2| resolve_profile_v2_config_path(&codex_home, profile_v2)); let loader_overrides = LoaderOverrides { + user_config_path, + user_config_profile: config_profile_v2, ignore_user_config, ignore_user_and_project_exec_policy_rules: ignore_rules, ..Default::default() diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 3f9c48df2..2935cabc8 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -87,6 +87,7 @@ mod tests { ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), + profile: None, }, config_with_hook_override(key, Some(/*enabled*/ false)), ), @@ -120,6 +121,7 @@ mod tests { ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), + profile: None, }, config_with_hook_state( key, @@ -175,6 +177,7 @@ mod tests { vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), + profile: None, }, config, )], @@ -215,6 +218,7 @@ mod tests { vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), + profile: None, }, config, )], diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 51d242efd..3ddf8f238 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -352,7 +352,7 @@ fn load_toml_hooks_from_layer( fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { match &layer.name { ConfigLayerSource::System { file } - | ConfigLayerSource::User { file } + | ConfigLayerSource::User { file, .. } | ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.clone(), ConfigLayerSource::Project { dot_codex_folder } => layer .hooks_config_folder() @@ -873,6 +873,7 @@ mod tests { let layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), + profile: None, }, config_with_malformed_state_and_session_start_hook(), ); @@ -972,6 +973,7 @@ mod tests { assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::User { file: config_file.clone(), + profile: None, }), (HookSource::User, false), ); diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index b7877b6a8..c3b757162 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -435,7 +435,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, user_config, )], ConfigRequirements { @@ -499,6 +502,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_config_path, + profile: None, }, config_with_hook_state(&managed_key, /*enabled*/ false), ), @@ -627,7 +631,10 @@ fn trusted_plugin_hook_stack( ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, config, )], ConfigRequirements::default(), @@ -716,7 +723,10 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, config_toml_with_pre_tool_use("python3 /tmp/user-hook.py"), )], requirements, @@ -767,7 +777,10 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, config_toml, )], ConfigRequirements::default(), @@ -834,7 +847,10 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { ); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, + ConfigLayerSource::User { + file: config_path, + profile: None, + }, config_toml_with_pre_tool_use("python3 /tmp/toml-hook.py"), )], requirements, diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 37e8d95e2..22c4f5154 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -9,7 +9,10 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value; use std::collections::HashMap; +use std::fmt; use std::num::NonZeroU64; +use std::ops::Deref; +use std::str::FromStr; use std::time::Duration; use strum_macros::Display; use strum_macros::EnumIter; @@ -77,6 +80,65 @@ pub enum SandboxMode { DangerFullAccess, } +/// Validated plain profile-v2 name used to select `$CODEX_HOME/.config.toml`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProfileV2Name(String); + +impl ProfileV2Name { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ProfileV2NameParseError { + value: String, +} + +impl fmt::Display for ProfileV2NameParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "invalid --profile-v2 value `{}`; pass a plain name such as `work`", + self.value + ) + } +} + +impl std::error::Error for ProfileV2NameParseError {} + +impl FromStr for ProfileV2Name { + type Err = ProfileV2NameParseError; + + fn from_str(value: &str) -> Result { + if value.is_empty() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(ProfileV2NameParseError { + value: value.to_string(), + }); + } + + Ok(Self(value.to_string())) + } +} + +impl Deref for ProfileV2Name { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for ProfileV2Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Display, TS)] #[strum(serialize_all = "snake_case")] #[ts(type = r#""user" | "auto_review" | "guardian_subagent""#)] @@ -690,6 +752,24 @@ mod tests { } } + #[test] + fn profile_v2_name_rejects_paths_and_empty_names() { + assert_eq!( + ProfileV2Name::from_str("../foo"), + Err(ProfileV2NameParseError { + value: "../foo".to_string(), + }), + "dots and slashes are disallowed to prevent reading arbitrary files" + ); + assert_eq!( + ProfileV2Name::from_str(""), + Err(ProfileV2NameParseError { + value: String::new(), + }), + "profile name cannot be empty" + ); + } + #[test] fn tui_visible_collaboration_modes_match_mode_kind_visibility() { let expected = [ModeKind::Default, ModeKind::Plan]; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index aa22e9383..7a74e0f95 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, runtime_approval_policy_override: Option, runtime_permission_profile_override: Option, @@ -629,6 +631,7 @@ impl App { mut config: Config, cli_kv_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, + loader_overrides: LoaderOverrides, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -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, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d20ffb821..26cf16a9e 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -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()], diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d936f4fe0..bff4479f0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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::>(); 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 diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 41972e675..aa281d453 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -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 diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index b52402ab4..a22107c22 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -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, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index fc095fc2b..2abecc358 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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, diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 401f9217b..746f42fce 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -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)) diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 3dd2d8235..7eb4539f1 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -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, diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs index 93f230253..3ec2ca390 100644 --- a/codex-rs/tui/src/external_agent_config_migration_startup.rs +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -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 diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8880935d0..32ca6617f 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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, ) -> Config { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index b511957d8..478ab05a9 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -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"); diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 35e4fd1da..98d2318d9 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -5,6 +5,7 @@ mod sandbox_mode_cli_arg; mod shared_options; pub use approval_mode_cli_arg::ApprovalModeCliArg; +pub use codex_protocol::config_types::ProfileV2Name; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; pub use sandbox_mode_cli_arg::SandboxModeCliArg; diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index 78b123f4e..8735cb6db 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -2,6 +2,7 @@ use crate::SandboxModeCliArg; use clap::Args; +use codex_protocol::config_types::ProfileV2Name; use std::path::PathBuf; #[derive(Args, Debug, Default)] @@ -33,6 +34,10 @@ pub struct SharedCliOptions { #[arg(long = "profile", short = 'p')] pub config_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile-v2")] + pub config_profile_v2: Option, + /// Select the sandbox policy to use when executing model-generated shell /// commands. #[arg(long = "sandbox", short = 's')] @@ -71,6 +76,7 @@ impl SharedCliOptions { oss, oss_provider, config_profile, + config_profile_v2, sandbox_mode, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -83,6 +89,7 @@ impl SharedCliOptions { oss: root_oss, oss_provider: root_oss_provider, config_profile: root_config_profile, + config_profile_v2: root_config_profile_v2, sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, bypass_hook_trust: root_bypass_hook_trust, @@ -102,6 +109,9 @@ impl SharedCliOptions { if config_profile.is_none() { config_profile.clone_from(root_config_profile); } + if config_profile_v2.is_none() { + config_profile_v2.clone_from(root_config_profile_v2); + } if sandbox_mode.is_none() { *sandbox_mode = *root_sandbox_mode; } @@ -136,6 +146,7 @@ impl SharedCliOptions { oss, oss_provider, config_profile, + config_profile_v2, sandbox_mode, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -155,6 +166,9 @@ impl SharedCliOptions { if let Some(config_profile) = config_profile { self.config_profile = Some(config_profile); } + if let Some(config_profile_v2) = config_profile_v2 { + self.config_profile_v2 = Some(config_profile_v2); + } if subcommand_selected_sandbox_mode { self.sandbox_mode = sandbox_mode; self.dangerously_bypass_approvals_and_sandbox =