feat: add layered --profile-v2 config files (#17141)

## Why

`--profile-v2 <name>` gives launchers and runtime entry points a named
profile config without making each profile duplicate the base user
config. The base `$CODEX_HOME/config.toml` still loads first, then
`$CODEX_HOME/<name>.config.toml` layers above it and becomes the active
writable user config for that session.

That keeps shared defaults, plugin/MCP setup, and managed/user
constraints in one place while letting a named profile override only the
pieces that need to differ.

## What Changed

- Added the shared `--profile-v2 <name>` runtime option with validated
plain names, now represented by `ProfileV2Name`.
- Extended config layer state so the base user config and selected
profile config are both `User` layers; APIs expose the active user layer
and merged effective user config.
- Threaded profile selection through runtime entry points: `codex`,
`codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex
debug prompt-input`.
- Made user-facing config writes go to the selected profile file when
active, including TUI/settings persistence, app-server config writes,
and MCP/app tool approval persistence.
- Made plugin, marketplace, MCP, hooks, and config reload paths read
from the merged user config so base and profile layers both participate.
- Updated app-server config layer schemas to mark profile-backed user
layers.

## Limits

`--profile-v2` is still rejected for config-management subcommands such
as feature, MCP, and marketplace edits. Those paths remain tied to the
base `config.toml` until they have explicit profile-selection semantics.

Some adjacent background writes may still update base or global state
rather than the selected profile:

- marketplace auto-upgrade metadata
- automatic MCP dependency installs from skills
- remote plugin sync or uninstall config edits
- personality migration marker/default writes

## Verification

Added targeted coverage for profile name validation, layer
ordering/merging, selected-profile writes, app-server config writes,
session hot reload, plugin config merging, hooks/config fixture updates,
and MCP/app approval persistence.

---------

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