Handle credential provider source remapping across config layers (#44241)

## What changed

- Let higher-priority providers in `features.network_proxy.credentials` displace lower-priority providers with overlapping `env` sources, including in profiles. Preserve inherited settings when providers swap sources, and compare environment names case-insensitively on Windows.
- Preserve provider definitions across ordered batch remaps and persist displaced-provider removals without overwriting unrelated sibling updates.
- Validate remapped providers using the credential broker's compilation rules before persisting writes, while allowing incomplete drafts and explicit deletions.
- Omit displaced providers from config origins and report writes overridden by another provider's source ownership.
- Build trusted credential-broker configuration before merging managed layers for project discovery, preserving remapped providers' environment protections.

## Testing

Add unit and config RPC regression tests for source precedence, source swaps, ordered edits, concurrent sibling updates, atomic rejection of invalid remaps, persistence, and override reporting. Add a macOS managed-config regression test for remapped provider bindings during project discovery.

GitOrigin-RevId: b7d402727acf4e5b0f25946db1cf18811f5e2679
This commit is contained in:
Winston Howes
2026-09-09 17:45:17 +00:00
committed by copyberry
parent 3436cad5ab
commit ed4ca07ba6
14 changed files with 1690 additions and 45 deletions
+1
View File
@@ -2212,6 +2212,7 @@ dependencies = [
"codex-model-provider",
"codex-model-provider-info",
"codex-models-manager",
"codex-network-proxy",
"codex-otel",
"codex-plugin",
"codex-protocol",
+1
View File
@@ -40,6 +40,7 @@ codex-aws-auth = { workspace = true }
codex-cloud-config = { workspace = true }
codex-code-mode = { workspace = true }
codex-config = { workspace = true }
codex-network-proxy = { workspace = true }
codex-connectors = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
@@ -24,7 +24,6 @@ use codex_config::merge_toml_values;
use codex_config::shell_environment_filter_entry;
use codex_config::validate_shell_environment_policy_filter_config;
use codex_core::config::deserialize_config_toml_with_base;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::validate_feature_requirements_for_config_toml;
use codex_core::path_utils;
@@ -42,6 +41,11 @@ use tokio::task;
use toml::Value as TomlValue;
use toml_edit::Item as TomlItem;
#[path = "config_manager_service_credential_edits.rs"]
mod credential_edits;
use credential_edits::CredentialProviderEdits;
#[derive(Debug, Error)]
pub(crate) enum ConfigManagerError {
#[error("{message}")]
@@ -254,6 +258,7 @@ impl ConfigManager {
}
let mut user_config = user_layer.config.clone();
let mut credential_provider_edits = CredentialProviderEdits::new(&user_config);
let mut parsed_segments = Vec::new();
let mut config_edits = Vec::new();
@@ -331,15 +336,6 @@ impl ConfigManager {
})?;
}
let persist_segments = if matches!(strategy, MergeStrategy::Upsert)
&& parsed_value.as_ref().is_some_and(|value| {
shell_environment_policy_representation_switch(&user_config, &segments, value)
}) {
vec!["shell_environment_policy".to_string()]
} else {
segments.clone()
};
let original_value = value_at_path(&user_config, &persist_segments).cloned();
let structured_feature_toggle = preserves_network_proxy_settings
|| is_structured_feature_path(&segments)
&& parsed_value.as_ref().is_some_and(|value| {
@@ -350,28 +346,13 @@ impl ConfigManager {
})
});
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|err| match err {
MergeError::Validation(message) => ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
message,
),
},
)?;
let updated_value = value_at_path(&user_config, &persist_segments).cloned();
if original_value != updated_value {
config_edits.push(match updated_value {
Some(value) => ConfigEdit::SetPath {
segments: persist_segments,
value: toml_value_to_item(&value).map_err(|err| {
ConfigManagerError::anyhow("failed to build config edits", err)
})?,
},
None => ConfigEdit::ClearPath {
segments: persist_segments,
},
});
if let Some(edit) = credential_provider_edits.apply(
&mut user_config,
&segments,
parsed_value.as_ref(),
strategy,
)? {
config_edits.push(edit);
}
if structured_feature_toggle {
@@ -379,6 +360,7 @@ impl ConfigManager {
}
parsed_segments.push(segments);
}
config_edits.extend(credential_provider_edits.remapping_edits(&user_config)?);
validate_config(&user_config).map_err(|err| {
ConfigManagerError::write(
@@ -414,12 +396,20 @@ impl ConfigManager {
)
})?;
let effective = updated_layers.effective_config();
validate_config(&effective).map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
validate_config(&effective)
.and_then(|()| {
credential_provider_edits.validate_remapping(
&user_layer.config,
&user_config,
&updated_layers,
)
})
.map_err(|err| {
ConfigManagerError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
if !config_edits.is_empty() {
ConfigEditsBuilder::for_config_path(provided_path.as_path())
@@ -577,6 +567,19 @@ fn structured_feature_depth(segments: &[String]) -> Option<usize> {
is_structured_feature_path(&segments[..depth]).then_some(depth)
}
fn credential_providers_path(segments: &[String]) -> Option<&[String]> {
match segments {
[features, proxy, credentials, ..]
if features == "features"
&& proxy == "network_proxy"
&& credentials == "credentials" =>
{
Some(&segments[..3])
}
_ => None,
}
}
fn apply_merge(
root: &mut TomlValue,
segments: &[String],
@@ -608,7 +611,8 @@ fn apply_merge(
if preserves_structured_feature_config
|| matches!(strategy, MergeStrategy::Upsert)
&& (shell_environment_policy_representation_switch(root, segments, value)
&& (credential_providers_path(segments).is_some()
|| shell_environment_policy_representation_switch(root, segments, value)
|| (matches!(value_at_path(root, segments), Some(TomlValue::Table(_)))
&& matches!(value, TomlValue::Table(_))))
{
@@ -866,6 +870,23 @@ fn find_effective_layer(
layers: &ConfigLayerStack,
segments: &[String],
) -> Option<ConfigLayerMetadata> {
if credential_providers_path(segments).is_some() {
// A provider can be displaced by another provider's environment sources,
// even when that layer never writes the edited path itself.
let mut merged = TomlValue::Table(Default::default());
let mut origin = None;
for layer in layers.layers_low_to_high() {
let previous = value_at_path(&merged, segments).cloned();
merge_toml_values(&mut merged, &layer.config);
if value_at_path(&layer.config, segments).is_some()
|| previous.as_ref() != value_at_path(&merged, segments)
{
origin = Some(layer.metadata());
}
}
return origin;
}
for layer in layers.layers_high_to_low() {
if value_at_semantic_path(&layer.config, segments).is_some() {
return Some(layer.metadata());
@@ -0,0 +1,295 @@
//! Prepare config edits and preserve provider definitions across source remapping in a write batch.
//! Explicit replacements and deletions remain ordered; only automatic sibling eviction is
//! ignored when retaining definitions for a later source upsert.
use super::ConfigManagerError;
use super::MergeError;
use super::apply_merge;
use super::shell_environment_policy_representation_switch;
use super::sparse_overlay;
use super::toml_value_to_item;
use super::value_at_path;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::MergeStrategy;
use codex_config::ConfigLayerStack;
use codex_config::merge_toml_values;
use codex_core::config::edit::ConfigEdit;
use codex_network_proxy::CredentialProviderConfig;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use toml::Value as TomlValue;
const PROVIDERS_PATH: [&str; 3] = ["features", "network_proxy", "credentials"];
pub(super) struct CredentialProviderEdits {
definitions: TomlValue,
displaced_provider_ids: BTreeSet<String>,
}
impl CredentialProviderEdits {
pub(super) fn new(config: &TomlValue) -> Self {
Self {
definitions: providers(config)
.cloned()
.unwrap_or_else(|| TomlValue::Table(Default::default())),
displaced_provider_ids: BTreeSet::new(),
}
}
pub(super) fn apply(
&mut self,
config: &mut TomlValue,
segments: &[String],
value: Option<&TomlValue>,
strategy: MergeStrategy,
) -> Result<Option<ConfigEdit>, ConfigManagerError> {
let persist_segments = if matches!(strategy, MergeStrategy::Upsert)
&& value.is_some_and(|value| {
shell_environment_policy_representation_switch(config, segments, value)
}) {
vec!["shell_environment_policy".to_string()]
} else {
segments.to_vec()
};
let original_value = value_at_path(config, &persist_segments).cloned();
self.apply_provider_merge(config, segments, value, strategy)
.map_err(|err| match err {
MergeError::Validation(message) => {
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
}
})?;
if original_value.as_ref() == value_at_path(config, &persist_segments) {
Ok(None)
} else {
config_edit_at_path(config, persist_segments).map(Some)
}
}
pub(super) fn remapping_edits(
&self,
config: &TomlValue,
) -> Result<Vec<ConfigEdit>, ConfigManagerError> {
// Use final definitions so later explicit edits and restored providers retain batch order.
self.displaced_provider_ids
.iter()
.map(|id| {
let mut segments = PROVIDERS_PATH.map(str::to_string).to_vec();
segments.push(id.clone());
config_edit_at_path(config, segments)
})
.collect()
}
fn apply_provider_merge(
&mut self,
config: &mut TomlValue,
segments: &[String],
value: Option<&TomlValue>,
strategy: MergeStrategy,
) -> Result<(), MergeError> {
if !segments
.iter()
.zip(PROVIDERS_PATH)
.all(|(segment, expected)| segment == expected)
{
return apply_merge(config, segments, value, strategy).map(|_| ());
}
let previous_ids = providers(config)
.and_then(TomlValue::as_table)
.filter(|_| matches!(strategy, MergeStrategy::Upsert) && value.is_some())
.into_iter()
.flat_map(|table| table.keys().cloned())
.collect::<BTreeSet<_>>();
let mut overlay = value.map(|value| sparse_overlay(segments, value));
let restored_ids = overlay
.as_ref()
.and_then(providers)
.and_then(TomlValue::as_table)
.into_iter()
.flatten()
.filter(|(id, update)| {
matches!(strategy, MergeStrategy::Upsert)
&& update.get("env").is_some()
&& self.definitions.get(id.as_str()).is_some()
&& self.definitions.get(id.as_str())
!= providers(config).and_then(|current| current.get(id.as_str()))
})
.map(|(id, _)| id.clone())
.collect::<Vec<_>>();
if segments.len() > PROVIDERS_PATH.len() {
apply_merge(
&mut self.definitions,
&segments[PROVIDERS_PATH.len()..],
value,
strategy.clone(),
)?;
} else {
let update = value.and_then(|value| {
PROVIDERS_PATH[segments.len()..]
.iter()
.try_fold(value, |value, key| value.get(*key))
});
if let Some(update) = update {
if matches!(strategy, MergeStrategy::Upsert) {
merge_toml_values(&mut self.definitions, update);
} else {
self.definitions = update.clone();
}
} else if value.is_none()
|| matches!(strategy, MergeStrategy::Replace)
&& !value.is_some_and(TomlValue::is_bool)
{
self.definitions = TomlValue::Table(Default::default());
}
}
if !restored_ids.is_empty()
&& let Some(overlay) = overlay.as_mut()
&& let Some(updates) = overlay
.get_mut("features")
.and_then(|features| features.get_mut("network_proxy"))
.and_then(|proxy| proxy.get_mut("credentials"))
.and_then(TomlValue::as_table_mut)
{
for id in restored_ids {
if let Some(definition) = self.definitions.get(&id) {
self.displaced_provider_ids.insert(id.clone());
updates.insert(id, definition.clone());
}
}
merge_toml_values(config, overlay);
} else {
apply_merge(config, segments, value, strategy)?;
}
self.displaced_provider_ids.extend(
previous_ids
.into_iter()
.filter(|id| providers(config).is_none_or(|table| table.get(id).is_none())),
);
Ok(())
}
pub(super) fn validate_remapping(
&self,
original_user_config: &TomlValue,
user_config: &TomlValue,
layers: &ConfigLayerStack,
) -> anyhow::Result<()> {
let mut previous = TomlValue::Table(Default::default());
let mut effective = previous.clone();
let mut user_reached = false;
let mut to_validate = BTreeMap::new();
for layer in layers.layers_low_to_high() {
let is_user = layers
.get_active_user_layer()
.is_some_and(|user| std::ptr::eq(layer, user));
merge_toml_values(
&mut previous,
if is_user {
original_user_config
} else {
&layer.config
},
);
merge_toml_values(&mut effective, &layer.config);
user_reached |= is_user;
if user_reached {
for (id, old) in providers(&previous)
.and_then(TomlValue::as_table)
.into_iter()
.flatten()
{
// Deleting an override and continuing an incomplete draft remain allowed.
if providers(user_config).is_none_or(|map| map.get(id).is_none()) {
continue;
}
let Some(current) = providers(&effective).and_then(|map| map.get(id)) else {
continue;
};
if current == old {
continue;
}
let Ok(old): Result<CredentialProviderConfig, _> = old.clone().try_into()
else {
continue;
};
if old.validate(id).is_ok() {
to_validate.insert(id.clone(), current.clone());
}
}
// Compare final ownership, not temporary source moves within the write batch.
for (id, definition) in providers(&effective)
.and_then(TomlValue::as_table)
.into_iter()
.flatten()
{
let receives_source = providers(&previous)
.and_then(TomlValue::as_table)
.into_iter()
.flatten()
.any(|(old_id, old)| {
// Retained definitions exclude explicit deletes, not automatic eviction.
(self.definitions.get(old_id).is_some()
|| self.definitions.get(id).is_some()
&& providers(original_user_config).and_then(|map| map.get(id))
!= providers(user_config).and_then(|map| map.get(id)))
&& sources(old).any(|old_source| {
!providers(&effective)
.and_then(|map| map.get(old_id))
.is_some_and(|current| {
sources(current)
.any(|source| same_source(source, old_source))
})
&& sources(definition)
.any(|source| same_source(source, old_source))
})
});
if receives_source || to_validate.contains_key(id) {
// A higher same-ID layer may complete a fragment; a different ID may not.
to_validate.insert(id.clone(), definition.clone());
}
}
}
}
for (id, definition) in to_validate {
let provider: CredentialProviderConfig = definition.try_into()?;
provider.validate(&id)?;
}
Ok(())
}
}
fn config_edit_at_path(
config: &TomlValue,
segments: Vec<String>,
) -> Result<ConfigEdit, ConfigManagerError> {
match value_at_path(config, &segments) {
Some(value) => Ok(ConfigEdit::SetPath {
value: toml_value_to_item(value)
.map_err(|err| ConfigManagerError::anyhow("failed to build config edits", err))?,
segments,
}),
None => Ok(ConfigEdit::ClearPath { segments }),
}
}
fn sources(provider: &TomlValue) -> impl Iterator<Item = &str> {
provider
.get("env")
.and_then(TomlValue::as_array)
.into_iter()
.flatten()
.filter_map(TomlValue::as_str)
}
fn same_source(left: &str, right: &str) -> bool {
left == right || cfg!(windows) && left.eq_ignore_ascii_case(right)
}
fn providers(config: &TomlValue) -> Option<&TomlValue> {
PROVIDERS_PATH
.iter()
.try_fold(config, |value, key| value.get(*key))
}
@@ -15,6 +15,605 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn provider_batch_remapping_preserves_ordered_edits() {
let initial = serde_json::json!({
"features": { "network_proxy": { "credentials": {
"a": { "env": ["A_AUTH"], "patterns": ["alpha"], "url_prefixes": ["https://a.example"] },
"b": { "env": ["B_AUTH"], "patterns": ["bravo"], "url_prefixes": ["https://b.example"], "auth": ["bearer"] },
} } },
});
let retained = serde_json::json!({
"env": ["A_AUTH"], "patterns": ["bravo"], "url_prefixes": ["https://b.example"],
});
let mut changed_auth = retained.clone();
changed_auth["auth"] = serde_json::json!(["token"]);
for (path, value, strategy, expected_b) in [
(
"b.auth",
serde_json::json!(["token"]),
MergeStrategy::Upsert,
changed_auth,
),
("b.auth", JsonValue::Null, MergeStrategy::Replace, retained),
(
"b",
JsonValue::Null,
MergeStrategy::Replace,
serde_json::json!({ "env": ["A_AUTH"] }),
),
(
"b",
serde_json::json!({ "patterns": ["replacement"], "url_prefixes": ["https://new.example"] }),
MergeStrategy::Replace,
serde_json::json!({ "env": ["A_AUTH"], "patterns": ["replacement"], "url_prefixes": ["https://new.example"] }),
),
(
"",
JsonValue::Null,
MergeStrategy::Replace,
serde_json::json!({ "env": ["A_AUTH"] }),
),
] {
let mut config: TomlValue = serde_json::from_value(initial.clone()).unwrap();
let tmp = tempdir().unwrap();
let file = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&file, toml::to_string(&config).unwrap()).unwrap();
let mut providers = CredentialProviderEdits::new(&config);
let mut persistence = Vec::new();
let path = if path.is_empty() {
"features.network_proxy.credentials".to_string()
} else {
format!("features.network_proxy.credentials.{path}")
};
for (key_path, value, strategy) in [
(
"features.network_proxy.credentials.a.env",
serde_json::json!(["B_AUTH"]),
MergeStrategy::Upsert,
),
(path.as_str(), value, strategy),
(
"features.network_proxy.credentials.b.env",
serde_json::json!(["A_AUTH"]),
MergeStrategy::Upsert,
),
] {
persistence.extend(
providers
.apply(
&mut config,
&parse_key_path(key_path).unwrap(),
parse_value(value).unwrap().as_ref(),
strategy,
)
.unwrap(),
);
}
persistence.extend(providers.remapping_edits(&config).unwrap());
ConfigEditsBuilder::for_config_path(&file)
.with_edits(persistence)
.apply_blocking()
.unwrap();
assert_eq!(
toml::from_str::<TomlValue>(&std::fs::read_to_string(&file).unwrap()).unwrap(),
config
);
let mut expected = serde_json::json!({ "b": expected_b });
if path != "features.network_proxy.credentials" {
expected["a"] = initial["features"]["network_proxy"]["credentials"]["a"].clone();
expected["a"]["env"] = serde_json::json!(["B_AUTH"]);
}
assert_eq!(
config["features"]["network_proxy"]["credentials"],
serde_json::from_value::<TomlValue>(expected).unwrap(),
"{path}"
);
}
}
#[tokio::test]
async fn provider_edits_preserve_another_writers_sibling_update() -> Result<()> {
let initial: TomlValue = toml::from_str(
r#"
[features.network_proxy.credentials.a]
env = ['A_AUTH']
patterns = ['^alpha_[a-z]{8}$']
url_prefixes = ['https://a.example']
auth = ['bearer']
[features.network_proxy.credentials.b]
env = ['B_AUTH']
patterns = ['^bravo_[a-z]{8}$']
url_prefixes = ['https://b.example']
auth = ['bearer']
[features.network_proxy.credentials.c]
env = ['C_AUTH']
patterns = ['^charlie_[a-z]{8}$']
url_prefixes = ['https://c.example']
auth = ['bearer']
"#,
)?;
for updates in [
vec![("a.auth", serde_json::json!(["token"]))],
vec![("a.env", serde_json::json!(["C_AUTH"]))],
vec![
("a.env", serde_json::json!(["C_AUTH"])),
("c.env", serde_json::json!(["A_AUTH"])),
],
] {
let tmp = tempdir()?;
let file = tmp.path().join(CONFIG_TOML_FILE);
std::fs::write(&file, toml::to_string(&initial)?)?;
let mut config = initial.clone();
let mut providers = CredentialProviderEdits::new(&config);
let mut persistence = Vec::new();
for (path, value) in updates {
persistence.extend(providers.apply(
&mut config,
&parse_key_path(&format!("features.network_proxy.credentials.{path}")).unwrap(),
parse_value(value).unwrap().as_ref(),
MergeStrategy::Upsert,
)?);
}
persistence.extend(providers.remapping_edits(&config)?);
// W1 has prepared its edits; W2 updates B before W1's persistence reread.
ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf())
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: "features.network_proxy.credentials.b.auth".to_string(),
value: serde_json::json!(["token"]),
merge_strategy: MergeStrategy::Upsert,
expected_version: None,
})
.await?;
ConfigEditsBuilder::for_config_path(&file)
.with_edits(persistence)
.apply()
.await?;
config["features"]["network_proxy"]["credentials"]["b"]["auth"] =
TomlValue::Array(vec![TomlValue::String("token".to_string())]);
assert_eq!(
toml::from_str::<TomlValue>(&std::fs::read_to_string(&file)?)?,
config
);
}
Ok(())
}
#[test]
fn provider_remapping_respects_lower_layer_eviction() -> Result<()> {
let tmp = tempdir()?;
let file = AbsolutePathBuf::try_from(tmp.path().join(CONFIG_TOML_FILE))?;
let complete = |id: &str| -> TomlValue {
toml::from_str(&format!(
"[features.network_proxy.credentials.{id}]\nenv = ['VENDOR_PASSWORD']\npatterns = ['^pin_[a-z]{{8}}$']\nurl_prefixes = ['https://old.example']\n"
)).unwrap()
};
// The middle layer must not let an evicted definition's fields reappear later.
for (lower_id, middle, user, valid, strategy, move_source) in [
("a_working", None, "", false, MergeStrategy::Upsert, false),
("a_working", None, "", false, MergeStrategy::Replace, false),
("z_new", None, "", true, MergeStrategy::Upsert, false),
("z_new", None, "", true, MergeStrategy::Replace, false),
(
"z_new",
Some("a_working"),
"",
false,
MergeStrategy::Upsert,
false,
),
(
"z_new",
Some("a_working"),
"a_working",
false,
MergeStrategy::Upsert,
false,
),
("a_working", None, "", false, MergeStrategy::Upsert, true),
(
"a_working",
None,
"a_working",
false,
MergeStrategy::Upsert,
true,
),
] {
let mut user_config = if user.is_empty() {
TomlValue::Table(Default::default())
} else {
complete(user)
};
let original_user_config = user_config.clone();
let mut edits = CredentialProviderEdits::new(&user_config);
if move_source {
edits
.apply(
&mut user_config,
&parse_key_path("features.network_proxy.credentials.a_working.env").unwrap(),
Some(&TomlValue::Array(vec![TomlValue::String(
"OTHER_PASSWORD".into(),
)])),
MergeStrategy::Upsert,
)
.unwrap();
}
edits
.apply(
&mut user_config,
&parse_key_path("features.network_proxy.credentials.z_new.env").unwrap(),
Some(&TomlValue::Array(vec![TomlValue::String(
"VENDOR_PASSWORD".into(),
)])),
strategy,
)
.unwrap();
let mut layers = vec![ConfigLayerEntry::new(
ConfigLayerSource::System { file: file.clone() },
complete(lower_id),
)];
if let Some(id) = middle {
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::EnterpriseManaged {
id: "test".into(),
name: "test".into(),
},
complete(id),
));
}
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::User {
file: file.clone(),
profile: None,
},
user_config.clone(),
));
let layers = ConfigLayerStack::new(layers, Default::default(), Default::default())?;
assert_eq!(
edits
.validate_remapping(&original_user_config, &user_config, &layers)
.is_ok(),
valid,
"{lower_id} / {middle:?} / {user}",
);
}
for pattern in ["[", "^pin_[a-z]{8}$"] {
for reverse in [false, true] {
let original = complete("a_working");
let mut config = original.clone();
let mut edits = CredentialProviderEdits::new(&config);
let mut updates = [
("a_working.env", serde_json::json!(["OTHER_PASSWORD"])),
("z_new.patterns", serde_json::json!([pattern])),
];
if reverse {
updates.reverse();
}
for (path, value) in updates {
edits
.apply(
&mut config,
&parse_key_path(&format!("features.network_proxy.credentials.{path}"))
.unwrap(),
parse_value(value).unwrap().as_ref(),
MergeStrategy::Upsert,
)
.unwrap();
}
let layers = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::System { file: file.clone() },
complete("z_new"),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: file.clone(),
profile: None,
},
config.clone(),
),
],
Default::default(),
Default::default(),
)?;
assert_eq!(
edits
.validate_remapping(&original, &config, &layers)
.is_ok(),
pattern != "[",
"{pattern}, reverse={reverse}"
);
}
}
Ok(())
}
#[test]
fn provider_updates_preserve_working_definitions_and_drafts() -> Result<()> {
let tmp = tempdir()?;
let file = AbsolutePathBuf::try_from(tmp.path().join(CONFIG_TOML_FILE))?;
let pattern = "^pin_[a-z]{8}$";
for (lower_pattern, initial, path, value, valid, managed_pattern) in [
(
pattern,
serde_json::json!({}),
"a.patterns",
serde_json::json!(["["]),
false,
None,
),
(
pattern,
serde_json::json!({}),
"a.auth",
serde_json::json!(["token"]),
true,
None,
),
(
"[",
serde_json::json!({"a": {"patterns": [pattern]}}),
"a",
JsonValue::Null,
true,
None,
),
(
pattern,
serde_json::json!({"a": {"patterns": ["["]}}),
"a.patterns",
serde_json::json!(["("]),
true,
None,
),
(
pattern,
serde_json::json!({"b": {"env": ["NEW_PASSWORD"]}}),
"b.patterns",
serde_json::json!(["["]),
true,
None,
),
(
pattern,
serde_json::json!({"b": {"env": ["NEW_PASSWORD"]}}),
"b",
serde_json::json!({"env": ["NEW_PASSWORD"], "patterns": [pattern]}),
true,
None,
),
(
pattern,
serde_json::json!({"b": {"env": ["NEW_PASSWORD"]}}),
"",
serde_json::json!({"b": {"env": ["NEW_PASSWORD"], "patterns": [pattern]}}),
true,
None,
),
(
"[",
serde_json::json!({"a": {"env": ["VENDOR_PASSWORD"]}}),
"a.env",
serde_json::json!([]),
false,
Some(pattern),
),
] {
let lower: TomlValue = serde_json::from_value(serde_json::json!({
"features": {"network_proxy": {"credentials": {"a": {
"env": ["VENDOR_PASSWORD"], "patterns": [lower_pattern],
"url_prefixes": ["https://api.vendor.example"],
}}}}
}))?;
let original: TomlValue = serde_json::from_value(serde_json::json!({
"features": {"network_proxy": {"credentials": initial}}
}))?;
let mut config = original.clone();
let mut edits = CredentialProviderEdits::new(&config);
let key_path = if path.is_empty() {
"features.network_proxy.credentials".to_string()
} else {
format!("features.network_proxy.credentials.{path}")
};
edits
.apply(
&mut config,
&parse_key_path(&key_path).unwrap(),
parse_value(value).unwrap().as_ref(),
MergeStrategy::Replace,
)
.unwrap();
let mut layers = vec![
ConfigLayerEntry::new(ConfigLayerSource::System { file: file.clone() }, lower),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: file.clone(),
profile: None,
},
config.clone(),
),
];
if let Some(pattern) = managed_pattern {
layers.push(ConfigLayerEntry::new(
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: file.clone() },
serde_json::from_value(serde_json::json!({"features": {"network_proxy": {"credentials": {"a": {"patterns": [pattern]}}}}}))?,
));
}
let layers = ConfigLayerStack::new(layers, Default::default(), Default::default())?;
assert_eq!(
edits
.validate_remapping(&original, &config, &layers)
.is_ok(),
valid,
"{path}"
);
}
Ok(())
}
#[test]
fn provider_draft_remapping_uses_final_source_ownership() -> Result<()> {
let tmp = tempdir()?;
let file = AbsolutePathBuf::try_from(tmp.path().join(CONFIG_TOML_FILE))?;
let config = |providers| -> TomlValue {
serde_json::from_value(serde_json::json!({
"features": {"network_proxy": {"credentials": providers}}
}))
.unwrap()
};
let complete = serde_json::json!({
"env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"],
"url_prefixes": ["https://api.vendor.example"],
});
let lower = config(serde_json::json!({"b": complete}));
let inherited_draft = config(serde_json::json!({
"a": complete, "b": {"url_prefixes": []},
}));
let inherited_only_draft = config(serde_json::json!({
"b": {"env": ["VENDOR_PASSWORD"]},
}));
let mut multi_source = serde_json::json!({"a": complete, "c": complete});
multi_source["a"]["env"] = serde_json::json!(["VENDOR_PASSWORD", "VENDOR_ALIAS"]);
multi_source["c"]["env"] = serde_json::json!(["OTHER_PASSWORD"]);
let mut actual = Vec::new();
let mut expected = Vec::new();
for (name, lower, original, updates, strategy, valid) in [
(
"automatic eviction reveals an inherited draft",
inherited_only_draft.clone(),
config(multi_source.clone()),
vec![("c.env", serde_json::json!(["VENDOR_ALIAS"]))],
MergeStrategy::Upsert,
false,
),
(
"explicit deletion after remapping remains allowed",
inherited_only_draft.clone(),
config(multi_source),
vec![
("c.env", serde_json::json!(["VENDOR_ALIAS"])),
("a", JsonValue::Null),
],
MergeStrategy::Upsert,
true,
),
(
"inherited-only draft receives the source",
inherited_only_draft.clone(),
config(serde_json::json!({"a": complete})),
vec![("a.env", serde_json::json!(["OTHER_PASSWORD"]))],
MergeStrategy::Replace,
false,
),
(
"deletion may reveal an inherited-only draft",
inherited_only_draft.clone(),
config(serde_json::json!({"a": complete})),
vec![("a", JsonValue::Null)],
MergeStrategy::Replace,
true,
),
(
"deleting both providers may reveal an inherited draft",
inherited_only_draft,
config(serde_json::json!({"a": complete, "b": {}})),
vec![("a", JsonValue::Null), ("b", JsonValue::Null)],
MergeStrategy::Replace,
true,
),
(
"inherited draft receives the source",
lower.clone(),
inherited_draft.clone(),
vec![("a.env", serde_json::json!(["OTHER_PASSWORD"]))],
MergeStrategy::Replace,
false,
),
(
"whole provider deletion remains allowed",
lower.clone(),
inherited_draft.clone(),
vec![("a", JsonValue::Null)],
MergeStrategy::Replace,
true,
),
(
"deletion with an unchanged draft edit",
lower.clone(),
inherited_draft.clone(),
vec![
("a", JsonValue::Null),
("b.url_prefixes", serde_json::json!([])),
],
MergeStrategy::Replace,
true,
),
(
"batch completes the recipient",
lower,
inherited_draft,
vec![
("a.env", serde_json::json!(["OTHER_PASSWORD"])),
("b.url_prefixes", serde_json::json!(["https://new.example"])),
],
MergeStrategy::Replace,
true,
),
(
"batch restores the draft source",
config(serde_json::json!({})),
config(serde_json::json!({"b": {"env": ["DRAFT_PASSWORD"]}})),
vec![
("b", serde_json::json!({"patterns": ["^pin_[a-z]{8}$"]})),
("b.env", serde_json::json!(["DRAFT_PASSWORD"])),
],
MergeStrategy::Replace,
true,
),
] {
let mut config = original.clone();
let mut edits = CredentialProviderEdits::new(&config);
for (path, value) in updates {
edits
.apply(
&mut config,
&parse_key_path(&format!("features.network_proxy.credentials.{path}")).unwrap(),
parse_value(value).unwrap().as_ref(),
strategy.clone(),
)
.unwrap();
}
let layers = ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(ConfigLayerSource::System { file: file.clone() }, lower),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: file.clone(),
profile: None,
},
config.clone(),
),
],
Default::default(),
Default::default(),
)?;
actual.push((
name,
edits
.validate_remapping(&original, &config, &layers)
.is_ok(),
));
expected.push((name, valid));
}
assert_eq!(actual, expected);
Ok(())
}
#[test]
fn toml_value_to_item_handles_nested_config_tables() {
let config = r#"
@@ -764,6 +1363,76 @@ async fn managed_auth_policy_survives_unusable_requirements_file_changes() -> Re
Ok(())
}
#[tokio::test]
async fn provider_remapping_validates_fragments_even_when_source_is_overridden() -> Result<()> {
for (managed_provider, strategy) in [
(None, MergeStrategy::Upsert),
(None, MergeStrategy::Replace),
(Some("z_new"), MergeStrategy::Upsert),
(Some("z_new"), MergeStrategy::Replace),
(Some("z_managed"), MergeStrategy::Upsert),
(Some("z_managed"), MergeStrategy::Replace),
] {
let tmp = tempdir()?;
let initial = r#"[features.network_proxy.credentials.a_working]
env = ["VENDOR_PASSWORD"]
patterns = ["^pin_[a-z]{8}$"]
url_prefixes = ["https://old.example"]
"#;
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), initial)?;
let managed_path = tmp.path().join("managed.toml");
if let Some(managed_provider) = managed_provider {
std::fs::write(
&managed_path,
format!(
r#"[features.network_proxy.credentials.{managed_provider}]
env = ["VENDOR_PASSWORD"]
patterns = ["^pin_[a-z]{{8}}$"]
url_prefix_from_env = "VENDOR_ENDPOINT"
"#
),
)?;
}
let service = ConfigManager::new_for_tests(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
CloudConfigBundleLoader::default(),
);
let result = service
.write_value(ConfigValueWriteParams {
file_path: None,
key_path: match strategy {
MergeStrategy::Upsert => "features.network_proxy.credentials.z_new.env",
MergeStrategy::Replace => "features.network_proxy.credentials",
}
.to_string(),
value: match strategy {
MergeStrategy::Upsert => serde_json::json!(["VENDOR_PASSWORD"]),
MergeStrategy::Replace => {
serde_json::json!({"z_new": {"env": ["VENDOR_PASSWORD"]}})
}
},
merge_strategy: strategy,
expected_version: None,
})
.await;
if managed_provider == Some("z_new") {
result?;
} else {
assert_eq!(
result.unwrap_err().write_error_code(),
Some(ConfigWriteErrorCode::ConfigValidationError)
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?,
initial
);
}
}
Ok(())
}
#[tokio::test]
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
let tmp = tempdir().expect("tempdir");
@@ -1546,6 +1546,423 @@ model = "gpt-old"
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_provider_write_reports_source_displacement() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, "")?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.with_args(&[
"-c",
"features.network_proxy.credentials.enforced.env=['VENDOR_PASSWORD']",
"-c",
"features.network_proxy.credentials.enforced.patterns=['^pin_[a-z]{8}$']",
"-c",
"features.network_proxy.credentials.enforced.url_prefixes=['https://fixed.example']",
])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let mut provider = json!({
"env": ["VENDOR_PASSWORD"],
"patterns": ["^pin_[a-z]{8}$"],
"url_prefixes": ["https://user.example"],
"auth": ["bearer"],
});
for (suffix, value, overridden) in [
("", provider.clone(), true),
(".url_prefixes", json!(["https://updated.example"]), true),
(".env", json!(["OTHER_PASSWORD"]), false),
] {
let write_id = app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: format!("features.network_proxy.credentials.user_provider{suffix}"),
value: value.clone(),
merge_strategy: MergeStrategy::Upsert,
expected_version: None,
})
.await?;
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(write_id)).await??;
let expected = overridden.then_some((ConfigLayerSource::SessionFlags, json!(null)));
assert_eq!(
(
write.status,
write
.overridden_metadata
.map(|meta| { (meta.overriding_layer.name, meta.effective_value) })
),
(
if overridden {
WriteStatus::OkOverridden
} else {
WriteStatus::Ok
},
expected,
),
);
if let Some(field) = suffix.strip_prefix('.') {
provider[field] = value;
}
let saved: toml::Value = toml::from_str(&std::fs::read_to_string(
codex_home.path().join("config.toml"),
)?)?;
assert_eq!(
serde_json::to_value(
&saved["features"]["network_proxy"]["credentials"]["user_provider"]
)?,
provider,
);
let read_id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(read_id)).await??;
assert_eq!(
read.config.additional["features"]["network_proxy"]["credentials"].get("user_provider"),
(!overridden).then_some(&provider),
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_provider_field_removal_uses_higher_same_id_definition() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"[features.network_proxy.credentials.vendor]
env = ["VENDOR_PASSWORD"]
patterns = ["^pin_[a-z]{8}$"]
url_prefixes = ["https://fixed.example"]
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_args(&[
"-c",
"features.network_proxy.credentials.vendor.url_prefixes=['https://fixed.example']",
])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let id = app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: "features.network_proxy.credentials.vendor.url_prefixes".to_string(),
value: json!(null),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await?;
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??;
assert_eq!(write.status, WriteStatus::OkOverridden);
let saved: toml::Value = toml::from_str(&std::fs::read_to_string(
codex_home.path().join("config.toml"),
)?)?;
assert_eq!(
serde_json::to_value(&saved["features"]["network_proxy"]["credentials"]["vendor"])?,
json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"] }),
);
let id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??;
assert_eq!(
read.config.additional["features"]["network_proxy"]["credentials"]["vendor"],
json!({
"env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"],
"url_prefixes": ["https://fixed.example"], "auth": [],
}),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_provider_remapping_rejects_invalid_replacements_atomically() -> Result<()> {
let codex_home = TempDir::new()?;
let initial = r#"[features.network_proxy]
enabled = true
credential_broker = true
[features.network_proxy.credentials.a_working]
env = ["VENDOR_PASSWORD"]
patterns = ["^pin_[a-z]{8}$"]
url_prefixes = ["https://api.vendor.example"]
"#;
write_config(&codex_home, initial)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
for (key_path, merge_strategy, replacement) in [
json!({ "env": ["VENDOR_PASSWORD"] }),
json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["["], "url_prefixes": ["https://api.vendor.example"] }),
json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["http://api.vendor.example"] }),
json!({ "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefixes": ["https://api.vendor.example"], "auth": ["header"] }),
]
.into_iter()
.flat_map(|replacement| [
("features.network_proxy.credentials.z_new", MergeStrategy::Upsert, replacement.clone()),
("features.network_proxy.credentials.a_working", MergeStrategy::Replace, replacement.clone()),
("features.network_proxy.credentials", MergeStrategy::Replace, json!({ "a_working": replacement })),
])
.chain([
("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Upsert, json!(["["])),
("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Replace, json!(["["])),
("features.network_proxy.credentials.a_working.patterns", MergeStrategy::Replace, json!(null)),
("features.network_proxy.credentials.a_working.url_prefixes", MergeStrategy::Upsert, json!(["http://api.vendor.example"])),
]) {
let id = app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: key_path.to_string(),
value: replacement,
merge_strategy,
expected_version: None,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(
error
.error
.data
.as_ref()
.and_then(|data| data.get("config_write_error_code")),
Some(&json!("configValidationError"))
);
assert_eq!(
std::fs::read_to_string(codex_home.path().join("config.toml"))?,
initial
);
}
let id = app_server
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: None,
edits: [
("a_working", "OTHER_PASSWORD"),
("b_incomplete", "VENDOR_PASSWORD"),
]
.into_iter()
.map(|(provider, source)| ConfigEdit {
key_path: format!("features.network_proxy.credentials.{provider}.env"),
value: json!([source]),
merge_strategy: MergeStrategy::Upsert,
})
.collect(),
expected_version: None,
reload_user_config: false,
})
.await?;
let error: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_error_message(RequestId::Integer(id)),
)
.await??;
assert_eq!(
error
.error
.data
.as_ref()
.and_then(|data| data.get("config_write_error_code")),
Some(&json!("configValidationError"))
);
assert_eq!(
std::fs::read_to_string(codex_home.path().join("config.toml"))?,
initial
);
let id = app_server
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: None,
edits: [
("env", json!(["VENDOR_PASSWORD"])),
("patterns", json!(["^pin_[a-z]{8}$"])),
("url_prefix_from_env", json!("VENDOR_ENDPOINT")),
]
.into_iter()
.map(|(field, value)| ConfigEdit {
key_path: format!("features.network_proxy.credentials.z_new.{field}"),
value,
merge_strategy: MergeStrategy::Upsert,
})
.collect(),
expected_version: None,
reload_user_config: false,
})
.await?;
let written: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??;
assert_eq!(written.status, WriteStatus::Ok);
let id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(id)).await??;
assert_eq!(
read.config.additional["features"]["network_proxy"]["credentials"],
json!({
"z_new": { "env": ["VENDOR_PASSWORD"], "patterns": ["^pin_[a-z]{8}$"], "url_prefix_from_env": "VENDOR_ENDPOINT", "url_prefixes": [], "auth": [] },
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_provider_upsert_persists_displaced_siblings() -> Result<()> {
let upsert = |path: &str, value| ConfigEdit {
key_path: format!("features.network_proxy.credentials{path}"),
value,
merge_strategy: MergeStrategy::Upsert,
};
for (mut edits, remapped_source) in [
(
vec![upsert(".z_new", json!({ "env": ["VENDOR_PASSWORD"] }))],
None,
),
(vec![upsert(".z_new.env", json!(["VENDOR_PASSWORD"]))], None),
(
vec![upsert(
"",
json!({ "z_new": { "env": ["VENDOR_PASSWORD"] } }),
)],
None,
),
(
vec![upsert(
"",
json!({
"a_old": { "env": ["NEW_PASSWORD"] },
"z_new": { "env": ["VENDOR_PASSWORD"] },
}),
)],
Some("NEW_PASSWORD"),
),
(
vec![
upsert(".z_new.env", json!(["VENDOR_PASSWORD"])),
upsert(".a_old.env", json!(["OTHER_PASSWORD"])),
],
Some("OTHER_PASSWORD"),
),
(
vec![
upsert(".z_new", json!({ "env": ["VENDOR_PASSWORD"] })),
ConfigEdit {
key_path: "model".to_string(),
value: json!("gpt-test"),
merge_strategy: MergeStrategy::Replace,
},
upsert("", json!({ "a_old": { "env": ["OTHER_PASSWORD"] } })),
],
Some("OTHER_PASSWORD"),
),
] {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"[features.network_proxy]
enabled = true
credential_broker = true
[features.network_proxy.credentials.a_old]
env = ["VENDOR_PASSWORD"]
patterns = ["^old_[a-z]{8}$"]
url_prefixes = ["https://old.example"]
auth = ["token"]
[features.network_proxy.credentials.z_new]
env = ["OTHER_PASSWORD"]
patterns = ["^new_[a-z]{8}$"]
url_prefixes = ["https://new.example"]
auth = ["bearer"]
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let mut expected = json!({
"z_new": {
"auth": ["bearer"],
"env": ["VENDOR_PASSWORD"],
"patterns": ["^new_[a-z]{8}$"],
"url_prefixes": ["https://new.example"],
}
});
if let Some(source) = remapped_source {
expected["a_old"] = json!({
"auth": ["token"],
"env": [source],
"patterns": ["^old_[a-z]{8}$"],
"url_prefixes": ["https://old.example"],
});
}
let write_id = if edits.len() == 1 {
let edit = edits.pop().unwrap();
app_server
.send_config_value_write_request(ConfigValueWriteParams {
file_path: None,
key_path: edit.key_path,
value: edit.value,
merge_strategy: edit.merge_strategy,
expected_version: None,
})
.await?
} else {
app_server
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: None,
edits,
expected_version: None,
reload_user_config: false,
})
.await?
};
let write: ConfigWriteResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(write_id)).await??;
assert_eq!(write.status, WriteStatus::Ok);
let read_id = app_server
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await?;
let read: ConfigReadResponse =
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(read_id)).await??;
assert_eq!(
read.config.additional["features"]["network_proxy"]["credentials"],
expected
);
assert_eq!(
read.origins["features.network_proxy.credentials.z_new.env.0"].version,
write.version
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_updates_desktop_settings() -> Result<()> {
let temp_dir = TempDir::new()?;
+3 -1
View File
@@ -126,6 +126,8 @@ pub(super) async fn load_local_config_layers_with_overrides(
let mut discovery_config = TomlValue::Table(toml::map::Map::new());
merge_toml_values(&mut discovery_config, &system.toml);
merge_toml_values(&mut discovery_config, &user.toml);
let trusted_broker_config =
credential_broker_trusted_config(&discovery_config, &[], &loaded_managed);
// Managed file and MDM values also govern the project boundary and trust.
// Only this snapshot is resolved; the returned local layers stay raw.
project_discovery::merge_managed_config_for_discovery(
@@ -141,7 +143,7 @@ pub(super) async fn load_local_config_layers_with_overrides(
let mut trust_context = project_trust_context(
fs,
&discovery_config,
&credential_broker_trusted_config(&discovery_config, &[], &loaded_managed),
&trusted_broker_config,
cwd,
&project_root_markers,
codex_home.as_path(),
@@ -262,6 +262,57 @@ async fn managed_project_discovery_uses_managed_project_trust() -> anyhow::Resul
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_project_discovery_preserves_remapped_provider_bindings() -> anyhow::Result<()> {
use base64::Engine;
use base64::prelude::BASE64_STANDARD;
let mut fixture = Fixture::new()?;
let providers = "[features.network_proxy.credentials]\n\
a = { env = ['A_AUTH'], patterns = ['^pin_[a-z]{8}$'], url_prefixes = ['https://a.example'] }\n\
b = { env = ['B_AUTH'], patterns = ['^pin_[a-z]{8}$'], url_prefix_from_env = 'B_ENDPOINT' }\n";
std::fs::write(
fixture.home.join("config.toml"),
format!(
"{}\n[features.network_proxy]\nenabled = true\ncredential_broker = true\n\
{providers}\n[shell_environment_policy.set]\nB_ENDPOINT = 'https://b.example'\n",
fixture.trust("trusted")
),
)?;
std::fs::write(
&fixture.managed,
"[features.network_proxy.credentials.a]\nenv = ['A_AUTH']\n",
)?;
fixture.overrides.managed_preferences_base64 = Some(BASE64_STANDARD.encode(
"[features.network_proxy.credentials]\na = { env = ['B_AUTH'] }\nb = { env = ['A_AUTH'] }\n",
));
std::fs::write(
fixture.cwd.join(".codex/config.toml"),
"[shell_environment_policy.set]\nB_ENDPOINT = 'https://attacker.example'\nTOOL_MODE = 'project'\n",
)?;
let (canonical, local) = fixture.load().await?;
let mut local_effective = TomlValue::Table(toml::map::Map::new());
for layer in local.config.layers {
merge_toml_values(&mut local_effective, &layer.toml);
}
let mut expected: TomlValue = toml::from_str(providers)?;
let credentials = &mut expected["features"]["network_proxy"]["credentials"];
credentials["a"]["env"] = TomlValue::try_from(["B_AUTH"])?;
credentials["b"]["env"] = TomlValue::try_from(["A_AUTH"])?;
let expected_policy: TomlValue =
toml::from_str("[set]\nB_ENDPOINT = 'https://b.example'\nTOOL_MODE = 'project'\n")?;
for effective in [canonical.effective_config(), local_effective] {
assert_eq!(
&effective["features"]["network_proxy"]["credentials"],
&*credentials
);
assert_eq!(effective["shell_environment_policy"], expected_policy);
}
Ok(())
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn managed_project_discovery_mdm_overrides_file_markers() -> anyhow::Result<()> {
+8 -5
View File
@@ -366,6 +366,11 @@ pub async fn load_config_layers_state(
if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() {
merge_toml_values(&mut merged_so_far, cli_overrides_layer);
}
let trusted_broker_config = credential_broker_trusted_config(
&merged_so_far,
&thread_config_layers,
&loaded_config_layers,
);
// Managed config wins over CLI config. Apply it before choosing the
// project root and trust, but keep its final layers above project config.
project_discovery::merge_managed_config_for_discovery(
@@ -390,11 +395,7 @@ pub async fn load_config_layers_state(
let mut project_trust_context = match project_trust_context(
fs,
&merged_so_far,
&credential_broker_trusted_config(
&merged_so_far,
&thread_config_layers,
&loaded_config_layers,
),
&trusted_broker_config,
&cwd,
&project_root_markers,
codex_home,
@@ -1357,6 +1358,8 @@ async fn project_trust_context(
})
}
// Start before managed layers are merged: replaying credential source remaps
// can discard provider fields needed to protect their environment bindings.
fn credential_broker_trusted_config(
merged_config: &TomlValue,
thread_config_layers: &[ConfigLayerEntry],
+32
View File
@@ -105,6 +105,38 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m
normalize_case_insensitive_keys(base_table);
normalize_case_insensitive_keys(&mut overlay_table);
}
if path.split_last().is_some_and(|(field, feature_path)| {
field == "credentials" && is_structured_feature_path(feature_path)
}) {
for (provider_id, provider) in &overlay_table {
let Some(overlay_sources) = provider.get("env").and_then(TomlValue::as_array)
else {
continue;
};
base_table.retain(|existing_id, existing| {
existing_id == provider_id
|| !overlay_table
.get(existing_id)
.and_then(|provider| provider.get("env"))
.or_else(|| existing.get("env"))
.and_then(TomlValue::as_array)
.is_some_and(|existing_sources| {
existing_sources.iter().filter_map(TomlValue::as_str).any(
|existing_source| {
overlay_sources.iter().filter_map(TomlValue::as_str).any(
|overlay_source| {
existing_source == overlay_source
|| cfg!(windows)
&& existing_source
.eq_ignore_ascii_case(overlay_source)
},
)
},
)
})
});
}
}
for (key, value) in overlay_table {
path.push(key.clone());
if let Some(existing) = base_table.get_mut(&key) {
+84
View File
@@ -312,6 +312,90 @@ fn network_proxy_feature_overrides_preserve_credential_broker_configuration() {
assert_eq!(base, expected);
}
#[test]
fn higher_priority_credential_provider_replaces_overlapping_sources() {
for (feature_path, base_source, overlay_source, keep_default) in [
("features", "VENDOR_TOKEN", "VENDOR_TOKEN", false),
(
"profiles.work.features",
"VENDOR_TOKEN",
"VENDOR_TOKEN",
false,
),
("features", "vendor_token", "VENDOR_TOKEN", !cfg!(windows)),
] {
let default_provider = format!(
"[{feature_path}.network_proxy.credentials.default]\n\
env = ['{base_source}']\n\
url_prefixes = ['https://default.example']\n"
);
let shared_provider = format!(
"[{feature_path}.network_proxy.credentials.shared]\n\
env = ['SHARED_TOKEN']\n\
patterns = ['shared_[a-z]+']\n"
);
let mut base = parse_toml(&format!("{default_provider}\n{shared_provider}"));
let overlay = parse_toml(&format!(
"[{feature_path}.network_proxy.credentials.override]\n\
env = ['{overlay_source}']\n\
url_prefixes = ['https://override.example']\n\
[{feature_path}.network_proxy.credentials.shared]\n\
url_prefixes = ['https://shared.example']\n"
));
merge_toml_values(&mut base, &overlay);
let retained_default = if keep_default { &default_provider } else { "" };
let expected = parse_toml(&format!(
"{retained_default}\n\
[{feature_path}.network_proxy.credentials.override]\n\
env = ['{overlay_source}']\n\
url_prefixes = ['https://override.example']\n\
[{feature_path}.network_proxy.credentials.shared]\n\
env = ['SHARED_TOKEN']\n\
patterns = ['shared_[a-z]+']\n\
url_prefixes = ['https://shared.example']\n"
));
assert_eq!(base, expected, "feature path: {feature_path}");
}
}
#[test]
fn credential_provider_remapping_preserves_inherited_settings() {
let base = parse_toml(
r#"
[features.network_proxy.credentials.a]
env = ["OLD_AUTH"]
patterns = ["acme_[a-z]{24}"]
url_prefixes = ["https://a.example"]
auth = ["token"]
[features.network_proxy.credentials.b]
env = ["OTHER_AUTH"]
patterns = ["other_[a-z]{24}"]
url_prefixes = ["https://b.example"]
auth = ["bearer"]
"#,
);
for new_a in ["NEW_AUTH", "OTHER_AUTH"] {
let mut merged = base.clone();
let mut expected = base.clone();
let providers = &mut expected["features"]["network_proxy"]["credentials"];
providers["a"]["env"] = TomlValue::Array(vec![new_a.into()]);
providers["b"]["env"] = TomlValue::Array(vec!["OLD_AUTH".into()]);
let overlay = parse_toml(&format!(
"[features.network_proxy.credentials.a]\n\
env = ['{new_a}']\n\
[features.network_proxy.credentials.b]\n\
env = ['OLD_AUTH']\n"
));
merge_toml_values(&mut merged, &overlay);
assert_eq!(merged, expected);
}
}
/// Repeated opaque desktop overrides continue to replace their previous value.
#[test]
fn multi_agent_v2_cli_compatibility_excludes_opaque_desktop_paths() {
+23
View File
@@ -466,12 +466,35 @@ impl ConfigLayerStack {
pub fn origins(&self) -> HashMap<String, ConfigLayerMetadata> {
let mut origins = HashMap::new();
let mut path = Vec::new();
let mut provider_paths = vec!["features.network_proxy.credentials.".to_string()];
for layer in self.layers_low_to_high() {
let config = normalized_with_key_aliases(&layer.config, &[]);
if let Some(profiles) = config.get("profiles").and_then(TomlValue::as_table) {
provider_paths.extend(
profiles
.keys()
.map(|name| format!("profiles.{name}.features.network_proxy.credentials.")),
);
}
record_origins(&config, &layer.metadata(), &mut path, &mut origins);
}
if let Some(layer) = self.layers_low_to_high().next_back() {
let effective = self.effective_config();
let mut effective_origins = HashMap::new();
record_origins(
&effective,
&layer.metadata(),
&mut path,
&mut effective_origins,
);
origins.retain(|path, _| {
!provider_paths.iter().any(|prefix| path.starts_with(prefix))
|| effective_origins.contains_key(path)
});
}
origins
}
+38
View File
@@ -78,6 +78,44 @@ fn origins_attribute_multi_agent_v2_enabled_to_overriding_boolean_layer() {
);
}
#[test]
fn origins_omit_displaced_credential_providers() {
let temp_dir = TempDir::new().expect("tempdir");
for scope in ["", "profiles.work."] {
let user = ConfigLayerEntry::new(
ConfigLayerSource::User {
file: test_user_config_path(&temp_dir, "config.toml"),
profile: None,
},
toml::from_str(&format!(
"[{scope}features.network_proxy.credentials.user_provider]\nenv = ['VENDOR_TOKEN']\n",
))
.expect("user config"),
);
let session = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(&format!(
"[{scope}features.network_proxy.credentials.session_provider]\nenv = ['VENDOR_TOKEN']\n",
))
.expect("session config"),
);
let metadata = session.metadata();
let stack = ConfigLayerStack::new(
vec![user, session],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid layers");
assert_eq!(
stack.origins(),
HashMap::from([(
format!("{scope}features.network_proxy.credentials.session_provider.env.0"),
metadata
)])
);
}
}
#[test]
fn enabled_layers_validate_shell_environment_policy() {
let layer = ConfigLayerEntry::new(
@@ -21,6 +21,14 @@ pub struct CredentialProviderConfig {
pub prefix: Option<String>,
}
impl CredentialProviderConfig {
/// Validate a complete provider definition using the broker's compilation rules.
pub fn validate(&self, id: &str) -> anyhow::Result<()> {
super::configured::ConfiguredCredentialProvider::compile(id, self)?;
Ok(())
}
}
/// Authentication formats supported by declarative credential providers.
#[derive(Clone, Copy, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]