mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: add layered --profile-v2 config files (#17141)
## Why `--profile-v2 <name>` gives launchers and runtime entry points a named profile config without making each profile duplicate the base user config. The base `$CODEX_HOME/config.toml` still loads first, then `$CODEX_HOME/<name>.config.toml` layers above it and becomes the active writable user config for that session. That keeps shared defaults, plugin/MCP setup, and managed/user constraints in one place while letting a named profile override only the pieces that need to differ. ## What Changed - Added the shared `--profile-v2 <name>` runtime option with validated plain names, now represented by `ProfileV2Name`. - Extended config layer state so the base user config and selected profile config are both `User` layers; APIs expose the active user layer and merged effective user config. - Threaded profile selection through runtime entry points: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`. - Made user-facing config writes go to the selected profile file when active, including TUI/settings persistence, app-server config writes, and MCP/app tool approval persistence. - Made plugin, marketplace, MCP, hooks, and config reload paths read from the merged user config so base and profile layers both participate. - Updated app-server config layer schemas to mark profile-backed user layers. ## Limits `--profile-v2` is still rejected for config-management subcommands such as feature, MCP, and marketplace edits. Those paths remain tied to the base `config.toml` until they have explicit profile-selection semantics. Some adjacent background writes may still update base or global state rather than the selected profile: - marketplace auto-upgrade metadata - automatic MCP dependency installs from skills - remote plugin sync or uninstall config edits - personality migration marker/default writes ## Verification Added targeted coverage for profile name validation, layer ordering/merging, selected-profile writes, app-server config writes, session hot reload, plugin config merging, hooks/config fixture updates, and MCP/app approval persistence. --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
17cd321c32
commit
deedf3b2c4
+7
@@ -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"
|
||||
|
||||
+7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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,
|
||||
|
||||
@@ -507,7 +507,7 @@ impl ExternalAgentConfigService {
|
||||
Ok(config) => {
|
||||
let configured_plugin_ids = config
|
||||
.config_layer_stack
|
||||
.get_user_layer()
|
||||
.get_active_user_layer()
|
||||
.and_then(|user_layer| user_layer.config.get("plugins"))
|
||||
.and_then(|plugins| {
|
||||
match plugins.clone().try_into::<HashMap<String, PluginConfig>>() {
|
||||
|
||||
@@ -62,6 +62,10 @@ impl ConfigManager {
|
||||
self.codex_home.as_path()
|
||||
}
|
||||
|
||||
pub(crate) fn user_config_path(&self) -> std::io::Result<AbsolutePathBuf> {
|
||||
self.loader_overrides.user_config_path(self.codex_home())
|
||||
}
|
||||
|
||||
pub(crate) fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
|
||||
self.cli_overrides
|
||||
.read()
|
||||
@@ -164,6 +168,16 @@ impl ConfigManager {
|
||||
self.current_cli_overrides(),
|
||||
)
|
||||
.await?;
|
||||
if self.loader_overrides.user_config_path.is_some()
|
||||
|| self.loader_overrides.user_config_profile.is_some()
|
||||
{
|
||||
let user_config_path = self.loader_overrides.user_config_path(self.codex_home())?;
|
||||
config.config_layer_stack = config.config_layer_stack.with_user_config_profile(
|
||||
&user_config_path,
|
||||
self.loader_overrides.user_config_profile.as_ref(),
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
);
|
||||
}
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
Ok(config)
|
||||
|
||||
@@ -196,8 +196,9 @@ impl ConfigManager {
|
||||
expected_version: Option<String>,
|
||||
edits: Vec<(String, JsonValue, MergeStrategy)>,
|
||||
) -> Result<ConfigWriteResponse, ConfigManagerError> {
|
||||
let allowed_path =
|
||||
AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, self.codex_home());
|
||||
let allowed_path = self
|
||||
.user_config_path()
|
||||
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?;
|
||||
let provided_path = match file_path {
|
||||
Some(path) => AbsolutePathBuf::from_absolute_path(PathBuf::from(path))
|
||||
.map_err(|err| ConfigManagerError::io("failed to resolve user config path", err))?,
|
||||
@@ -215,7 +216,7 @@ impl ConfigManager {
|
||||
.load_thread_agnostic_config()
|
||||
.await
|
||||
.map_err(|err| ConfigManagerError::io("failed to load configuration", err))?;
|
||||
let user_layer = match layers.get_user_layer() {
|
||||
let user_layer = match layers.get_active_user_layer() {
|
||||
Some(layer) => Cow::Borrowed(layer),
|
||||
None => Cow::Owned(create_empty_user_layer(&allowed_path).await?),
|
||||
};
|
||||
@@ -305,7 +306,7 @@ impl ConfigManager {
|
||||
})?;
|
||||
|
||||
if !config_edits.is_empty() {
|
||||
ConfigEditsBuilder::new(self.codex_home())
|
||||
ConfigEditsBuilder::for_config_path(provided_path.as_path())
|
||||
.with_edits(config_edits)
|
||||
.apply()
|
||||
.await
|
||||
@@ -321,7 +322,7 @@ impl ConfigManager {
|
||||
Ok(ConfigWriteResponse {
|
||||
status,
|
||||
version: updated_layers
|
||||
.get_user_layer()
|
||||
.get_active_user_layer()
|
||||
.ok_or_else(|| {
|
||||
ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::UserLayerNotFound,
|
||||
@@ -375,6 +376,7 @@ async fn create_empty_user_layer(
|
||||
Ok(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: config_toml.clone(),
|
||||
profile: None,
|
||||
},
|
||||
toml_value,
|
||||
))
|
||||
@@ -574,7 +576,7 @@ fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
dot_codex_folder.display(),
|
||||
),
|
||||
ConfigLayerSource::SessionFlags => "Overridden by session flags".to_string(),
|
||||
ConfigLayerSource::User { file } => {
|
||||
ConfigLayerSource::User { file, .. } => {
|
||||
format!("Overridden by user config: {}", file.display())
|
||||
}
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => {
|
||||
@@ -594,7 +596,7 @@ fn compute_override_metadata(
|
||||
effective: &TomlValue,
|
||||
segments: &[String],
|
||||
) -> Option<OverriddenMetadata> {
|
||||
let user_value = match layers.get_user_layer() {
|
||||
let user_value = match layers.get_active_user_layer() {
|
||||
Some(user_layer) => value_at_path(&user_layer.config, segments),
|
||||
None => return None,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<&'a ProfileV2Name>> {
|
||||
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<LoaderOverrides> {
|
||||
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<Option<String>> {
|
||||
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)
|
||||
|
||||
@@ -229,7 +229,7 @@ where
|
||||
fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Option<PathBuf> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}/<name>.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<ConfigLayerEntry> {
|
||||
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<ConfigLayerEntry>, layer: ConfigLayerEntry) {
|
||||
match layers
|
||||
.iter()
|
||||
|
||||
+161
-50
@@ -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<LoaderOverrides> for ConfigLoadOptions {
|
||||
/// LoaderOverrides overrides managed configuration inputs (primarily for tests).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LoaderOverrides {
|
||||
pub user_config_path: Option<AbsolutePathBuf>,
|
||||
pub user_config_profile: Option<ProfileV2Name>,
|
||||
pub managed_config_path: Option<PathBuf>,
|
||||
pub system_config_path: Option<PathBuf>,
|
||||
pub system_requirements_path: Option<PathBuf>,
|
||||
@@ -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<AbsolutePathBuf> {
|
||||
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<ConfigLayerEntry>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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<TomlValue> {
|
||||
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::<ProfileV2Name>().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<ConfigLayerEntry>) -> 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::<Vec<_>>();
|
||||
let mut layers = self
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| !matches!(layer.name, ConfigLayerSource::User { .. }))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
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<Option<usize>> {
|
||||
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<Option<
|
||||
}
|
||||
|
||||
// The previous check ensured `layers` is sorted by precedence, so now we
|
||||
// further verify that:
|
||||
// 1. There is at most one user config layer.
|
||||
// 2. Project layers are ordered from root to cwd.
|
||||
// further verify that project layers are ordered from root to cwd. Multiple
|
||||
// user layers are allowed so a profile override can layer on top of the base
|
||||
// user config.
|
||||
let mut user_layer_index: Option<usize> = 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ pub fn installed_marketplace_roots_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
codex_home: &Path,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
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 {
|
||||
|
||||
@@ -378,10 +378,10 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
fn configured_plugins_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1997,10 +1997,10 @@ pub(crate) fn configured_plugins_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
// 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(
|
||||
|
||||
@@ -108,10 +108,10 @@ fn marketplace_install_root(codex_home: &Path) -> PathBuf {
|
||||
fn configured_git_marketplaces(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> Vec<ConfiguredGitMarketplace> {
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)],
|
||||
|
||||
@@ -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::<DocumentMut>()?
|
||||
};
|
||||
|
||||
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.<name>]` 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<ConfigEdit>,
|
||||
) -> 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<String>,
|
||||
edits: Vec<ConfigEdit>,
|
||||
}
|
||||
|
||||
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")?
|
||||
|
||||
@@ -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<u64> = 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<PathBuf> {
|
||||
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<Self> {
|
||||
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<ConfigToml> {
|
||||
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
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(crate) fn lock_layer_from_config(
|
||||
Ok(ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: lock_path.clone(),
|
||||
profile: None,
|
||||
},
|
||||
value,
|
||||
))
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
|
||||
@@ -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<Option<AbsolutePathBuf>> {
|
||||
) -> anyhow::Result<Option<ConfigEditsBuilder>> {
|
||||
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<bool> {
|
||||
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::<String, codex_config::types::McpServerConfig>::deserialize(mcp_servers_toml)?;
|
||||
Ok(servers.contains_key(server))
|
||||
}
|
||||
|
||||
fn project_mcp_tool_approval_config_folder(
|
||||
config: &Config,
|
||||
server: &str,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -90,7 +90,7 @@ fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec<LayerMtime> {
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<toml::Value>(&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::<toml::Value>(&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
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn trusted_config_layer_stack(
|
||||
hooks: Vec<HookListEntry>,
|
||||
) -> 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 {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)],
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/<name>.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<Self, Self::Err> {
|
||||
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];
|
||||
|
||||
@@ -125,6 +125,7 @@ use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnError as AppServerTurnError;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_exec_server::EnvironmentManager;
|
||||
@@ -465,6 +466,7 @@ pub(crate) struct App {
|
||||
pub(crate) active_profile: Option<String>,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
loader_overrides: LoaderOverrides,
|
||||
runtime_approval_policy_override: Option<AskForApproval>,
|
||||
runtime_permission_profile_override: Option<PermissionProfile>,
|
||||
|
||||
@@ -629,6 +631,7 @@ impl App {
|
||||
mut config: Config,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
loader_overrides: LoaderOverrides,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -900,6 +903,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
active_profile,
|
||||
cli_kv_overrides,
|
||||
harness_overrides,
|
||||
loader_overrides,
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
|
||||
@@ -15,6 +15,7 @@ impl App {
|
||||
.codex_home(self.config.codex_home.to_path_buf())
|
||||
.cli_overrides(self.cli_kv_overrides.clone())
|
||||
.harness_overrides(overrides)
|
||||
.loader_overrides(self.loader_overrides.clone())
|
||||
.build()
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
||||
@@ -170,7 +171,7 @@ impl App {
|
||||
(root_blocks_disable, profile_configured)
|
||||
};
|
||||
let mut permissions_history_label: Option<&'static str> = None;
|
||||
let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let mut builder = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(self.active_profile.as_deref());
|
||||
|
||||
for (feature, enabled) in updates {
|
||||
@@ -407,7 +408,7 @@ impl App {
|
||||
},
|
||||
];
|
||||
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
@@ -591,7 +592,7 @@ mod tests {
|
||||
|
||||
assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None);
|
||||
|
||||
ConfigEditsBuilder::new(&app.config.codex_home)
|
||||
ConfigEditsBuilder::for_config(&app.config)
|
||||
.with_edits([
|
||||
ConfigEdit::SetPath {
|
||||
segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()],
|
||||
|
||||
@@ -1079,7 +1079,7 @@ impl App {
|
||||
}
|
||||
let profile = self.active_profile.as_deref();
|
||||
let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated);
|
||||
let builder = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let builder = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_windows_sandbox_mode(if elevated_enabled {
|
||||
"elevated"
|
||||
@@ -1182,7 +1182,7 @@ impl App {
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_model(Some(model.as_str()), effort)
|
||||
.apply()
|
||||
@@ -1260,7 +1260,7 @@ impl App {
|
||||
}
|
||||
AppEvent::PersistPersonalitySelection { personality } => {
|
||||
let profile = self.active_profile.as_deref();
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_personality(Some(personality))
|
||||
.apply()
|
||||
@@ -1297,7 +1297,7 @@ impl App {
|
||||
self.refresh_status_line();
|
||||
let profile = self.active_profile.as_deref();
|
||||
self.config.service_tier = service_tier.clone();
|
||||
let mut edits = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let mut edits = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.set_service_tier(service_tier.clone());
|
||||
if service_tier.is_none() {
|
||||
@@ -1335,11 +1335,11 @@ impl App {
|
||||
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
|
||||
let builder = match kind {
|
||||
RealtimeAudioDeviceKind::Microphone => {
|
||||
ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_realtime_microphone(name.as_deref())
|
||||
}
|
||||
RealtimeAudioDeviceKind::Speaker => {
|
||||
ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_realtime_speaker(name.as_deref())
|
||||
}
|
||||
};
|
||||
@@ -1476,7 +1476,7 @@ impl App {
|
||||
} else {
|
||||
vec!["approvals_reviewer".to_string()]
|
||||
};
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_profile(profile)
|
||||
.with_edits([ConfigEdit::SetPath {
|
||||
segments,
|
||||
@@ -1528,7 +1528,7 @@ impl App {
|
||||
self.chat_widget.set_plan_mode_reasoning_effort(effort);
|
||||
}
|
||||
AppEvent::PersistFullAccessWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_hide_full_access_warning(/*acknowledged*/ true)
|
||||
.apply()
|
||||
.await
|
||||
@@ -1543,7 +1543,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::PersistWorldWritableWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_hide_world_writable_warning(/*acknowledged*/ true)
|
||||
.apply()
|
||||
.await
|
||||
@@ -1558,7 +1558,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
|
||||
.apply()
|
||||
.await
|
||||
@@ -1591,7 +1591,7 @@ impl App {
|
||||
} else {
|
||||
ConfigEdit::ClearPath { segments }
|
||||
};
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
@@ -1615,7 +1615,7 @@ impl App {
|
||||
from_model,
|
||||
to_model,
|
||||
} => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
|
||||
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
|
||||
.apply()
|
||||
.await
|
||||
@@ -1658,7 +1658,7 @@ impl App {
|
||||
path: path.to_path_buf(),
|
||||
enabled,
|
||||
}];
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
@@ -1710,7 +1710,7 @@ impl App {
|
||||
},
|
||||
]
|
||||
};
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
@@ -1873,7 +1873,7 @@ impl App {
|
||||
let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids);
|
||||
let colors_edit =
|
||||
crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([items_edit, colors_edit])
|
||||
.apply()
|
||||
.await;
|
||||
@@ -1905,7 +1905,7 @@ impl App {
|
||||
AppEvent::TerminalTitleSetup { items } => {
|
||||
let ids = items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await;
|
||||
@@ -1931,7 +1931,7 @@ impl App {
|
||||
}
|
||||
AppEvent::SyntaxThemeSelected { name } => {
|
||||
let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name);
|
||||
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
let apply_result = ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await;
|
||||
@@ -2043,7 +2043,7 @@ impl App {
|
||||
|
||||
let edit =
|
||||
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
@@ -2088,7 +2088,7 @@ impl App {
|
||||
};
|
||||
|
||||
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
match ConfigEditsBuilder::for_config(&self.config)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
|
||||
@@ -209,7 +209,7 @@ pub(super) async fn prepare_startup_tooltip_override(
|
||||
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
|
||||
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
|
||||
|
||||
if let Err(err) = ConfigEditsBuilder::new(&config.codex_home)
|
||||
if let Err(err) = ConfigEditsBuilder::for_config(config)
|
||||
.set_model_availability_nux_count(&updated_shown_count)
|
||||
.apply()
|
||||
.await
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
|
||||
@@ -3489,35 +3489,38 @@ async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
|
||||
|
||||
#[tokio::test]
|
||||
async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
app.agent_navigation.upsert(
|
||||
side_thread_id,
|
||||
Some("Side".to_string()),
|
||||
Some("side".to_string()),
|
||||
/*is_closed*/ false,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!app.discard_side_thread(&mut app_server, side_thread_id)
|
||||
.await
|
||||
);
|
||||
|
||||
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
||||
assert_eq!(
|
||||
Box::pin(async {
|
||||
let mut app = make_test_app().await;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.get(&side_thread_id)
|
||||
.map(|state| state.parent_thread_id),
|
||||
Some(parent_thread_id)
|
||||
);
|
||||
assert!(app.agent_navigation.get(&side_thread_id).is_some());
|
||||
Ok(())
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
app.agent_navigation.upsert(
|
||||
side_thread_id,
|
||||
Some("Side".to_string()),
|
||||
Some("side".to_string()),
|
||||
/*is_closed*/ false,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!app.discard_side_thread(&mut app_server, side_thread_id)
|
||||
.await
|
||||
);
|
||||
|
||||
assert_eq!(app.active_thread_id, Some(side_thread_id));
|
||||
assert_eq!(
|
||||
app.side_threads
|
||||
.get(&side_thread_id)
|
||||
.map(|state| state.parent_thread_id),
|
||||
Some(parent_thread_id)
|
||||
);
|
||||
assert!(app.agent_navigation.get(&side_thread_id).is_some());
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3810,6 +3813,7 @@ async fn make_test_app() -> App {
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
@@ -3872,6 +3876,7 @@ async fn make_test_app_with_channels() -> (
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
loader_overrides: LoaderOverrides::without_managed_config_for_tests(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -385,7 +385,7 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String {
|
||||
ConfigLayerSource::System { file } => {
|
||||
format!("system ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::User { file } => {
|
||||
ConfigLayerSource::User { file, .. } => {
|
||||
format!("user ({})", file.as_path().display())
|
||||
}
|
||||
ConfigLayerSource::Project { dot_codex_folder } => {
|
||||
@@ -728,7 +728,10 @@ mod tests {
|
||||
};
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
ConfigLayerSource::User {
|
||||
file: user_file,
|
||||
profile: None,
|
||||
},
|
||||
empty_toml_table(),
|
||||
)],
|
||||
requirements,
|
||||
|
||||
@@ -148,7 +148,7 @@ async fn persist_external_agent_config_migration_prompt_shown(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ConfigEditsBuilder::new(&config.codex_home)
|
||||
ConfigEditsBuilder::for_config(config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
@@ -221,7 +221,7 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ConfigEditsBuilder::new(&config.codex_home)
|
||||
ConfigEditsBuilder::for_config(config)
|
||||
.with_edits(edits)
|
||||
.apply()
|
||||
.await
|
||||
|
||||
+17
-9
@@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides;
|
||||
use crate::legacy_core::config::find_codex_home;
|
||||
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
|
||||
use crate::legacy_core::config::resolve_oss_provider;
|
||||
use crate::legacy_core::config::resolve_profile_v2_config_path;
|
||||
use crate::legacy_core::format_exec_policy_error_with_source;
|
||||
use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use crate::session_resume::ResolveCwdOutcome;
|
||||
@@ -842,6 +843,12 @@ pub async fn run_main(
|
||||
let cwd = cli.cwd.clone();
|
||||
let config_cwd =
|
||||
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
|
||||
let mut loader_overrides = loader_overrides;
|
||||
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
|
||||
let user_config_path = resolve_profile_v2_config_path(&codex_home, profile_v2);
|
||||
loader_overrides.user_config_path = Some(user_config_path);
|
||||
loader_overrides.user_config_profile = Some(profile_v2.clone());
|
||||
}
|
||||
|
||||
#[allow(clippy::print_stderr)]
|
||||
let config_toml = match load_config_as_toml_with_cli_and_load_options(
|
||||
@@ -946,8 +953,8 @@ pub async fn run_main(
|
||||
let mut config = load_config_or_exit(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
loader_overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
strict_config,
|
||||
)
|
||||
.await;
|
||||
@@ -1003,8 +1010,8 @@ pub async fn run_main(
|
||||
config = load_config_or_exit(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
loader_overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
strict_config,
|
||||
)
|
||||
.await;
|
||||
@@ -1321,8 +1328,8 @@ async fn run_ratatui_app(
|
||||
load_config_or_exit(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
loader_overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
strict_config,
|
||||
)
|
||||
.await
|
||||
@@ -1525,8 +1532,8 @@ async fn run_ratatui_app(
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
loader_overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
strict_config,
|
||||
fallback_cwd,
|
||||
)
|
||||
@@ -1536,8 +1543,8 @@ async fn run_ratatui_app(
|
||||
load_config_or_exit(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
loader_overrides.clone(),
|
||||
cloud_requirements.clone(),
|
||||
strict_config,
|
||||
)
|
||||
.await
|
||||
@@ -1579,7 +1586,7 @@ async fn run_ratatui_app(
|
||||
arg0_paths,
|
||||
config.clone(),
|
||||
cli_kv_overrides.clone(),
|
||||
loader_overrides,
|
||||
loader_overrides.clone(),
|
||||
strict_config,
|
||||
cloud_requirements.clone(),
|
||||
feedback.clone(),
|
||||
@@ -1611,6 +1618,7 @@ async fn run_ratatui_app(
|
||||
config,
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
loader_overrides.clone(),
|
||||
active_profile,
|
||||
prompt,
|
||||
images,
|
||||
@@ -1721,15 +1729,15 @@ async fn get_login_status(
|
||||
async fn load_config_or_exit(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
strict_config: bool,
|
||||
) -> Config {
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides,
|
||||
overrides,
|
||||
cloud_requirements,
|
||||
loader_overrides,
|
||||
cloud_requirements,
|
||||
strict_config,
|
||||
/*fallback_cwd*/ None,
|
||||
)
|
||||
@@ -1739,8 +1747,8 @@ async fn load_config_or_exit(
|
||||
async fn load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides: Vec<(String, toml::Value)>,
|
||||
overrides: ConfigOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
loader_overrides: LoaderOverrides,
|
||||
cloud_requirements: CloudRequirementsLoader,
|
||||
strict_config: bool,
|
||||
fallback_cwd: Option<PathBuf>,
|
||||
) -> Config {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
|
||||
#[arg(long = "profile-v2")]
|
||||
pub config_profile_v2: Option<ProfileV2Name>,
|
||||
|
||||
/// 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 =
|
||||
|
||||
Reference in New Issue
Block a user