mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
## What changed Add `modelProvider/config/write` to create custom gateways and patch existing custom gateway, Bedrock, and GCP settings. Return the saved provider using the same safe summary as `modelProvider/list`. Omitted fields retain their values; explicit `null` clears nullable user overrides. Apply patches through the shared configuration queue with last-write-wins semantics, enforce field editability and validation, and preserve unexposed authentication settings. Saving leaves provider selection and authentication unchanged. Saving the provider used by `model/list` invalidates its catalog for the next access. Include documentation and generated JSON, TypeScript, and Python protocol types. ## Testing Add coverage for gateway creation and ID allocation, patch and null semantics, configuration policy enforcement, authentication preservation, and catalog refresh after active-provider edits. Extend Python protocol tests to verify wire round trips and reject mismatched provider IDs or fields. GitOrigin-RevId: 850fe2c48ef5274944400efb5a859d720179c923
3676 lines
123 KiB
Rust
3676 lines
123 KiB
Rust
use super::*;
|
|
use anyhow::Result;
|
|
use axum::http::HeaderValue;
|
|
use codex_app_server_protocol::AppConfig;
|
|
use codex_app_server_protocol::AppToolApproval;
|
|
use codex_app_server_protocol::AppsConfig;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_app_server_protocol::ConfigLayerSource as ApiConfigLayerSource;
|
|
use codex_config::CloudConfigBundleLoader;
|
|
use codex_config::LoaderOverrides;
|
|
use codex_config::test_support::CloudConfigBundleFixture;
|
|
use codex_config::types::OtelExporterKind;
|
|
use codex_http_client::HttpClientFactory;
|
|
use codex_http_client::OutboundProxyPolicy;
|
|
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#"
|
|
[mcp_servers.docs]
|
|
command = "docs-server"
|
|
|
|
[mcp_servers.docs.http_headers]
|
|
X-Doc = "42"
|
|
"#;
|
|
|
|
let value: TomlValue = toml::from_str(config).expect("parse config example");
|
|
let item = toml_value_to_item(&value).expect("convert to toml_edit item");
|
|
|
|
let root = item.as_table().expect("root table");
|
|
assert!(!root.is_implicit(), "root table should be explicit");
|
|
|
|
let mcp_servers = root
|
|
.get("mcp_servers")
|
|
.and_then(TomlItem::as_table)
|
|
.expect("mcp_servers table");
|
|
assert!(
|
|
!mcp_servers.is_implicit(),
|
|
"mcp_servers table should be explicit"
|
|
);
|
|
|
|
let docs = mcp_servers
|
|
.get("docs")
|
|
.and_then(TomlItem::as_table)
|
|
.expect("docs table");
|
|
assert_eq!(
|
|
docs.get("command")
|
|
.and_then(TomlItem::as_value)
|
|
.and_then(toml_edit::Value::as_str),
|
|
Some("docs-server")
|
|
);
|
|
|
|
let http_headers = docs
|
|
.get("http_headers")
|
|
.and_then(TomlItem::as_table)
|
|
.expect("http_headers table");
|
|
assert_eq!(
|
|
http_headers
|
|
.get("X-Doc")
|
|
.and_then(TomlItem::as_value)
|
|
.and_then(toml_edit::Value::as_str),
|
|
Some("42")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_preserves_comments_and_order() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let original = r#"# Codex user configuration
|
|
model = "gpt-5.2"
|
|
approval_policy = "on-request"
|
|
|
|
[notice]
|
|
# Preserve this comment
|
|
hide_full_access_warning = true
|
|
|
|
[features]
|
|
unified_exec = true
|
|
"#;
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), original)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "features.personality".to_string(),
|
|
value: serde_json::json!(true),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write succeeds");
|
|
|
|
let updated = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
|
let expected = r#"# Codex user configuration
|
|
model = "gpt-5.2"
|
|
approval_policy = "on-request"
|
|
|
|
[notice]
|
|
# Preserve this comment
|
|
hide_full_access_warning = true
|
|
|
|
[features]
|
|
unified_exec = true
|
|
personality = true
|
|
"#;
|
|
assert_eq!(updated, expected);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn psp_feature_configures_first_party_routing() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let config = service
|
|
.load_with_overrides(
|
|
Some(
|
|
[(
|
|
"features".to_string(),
|
|
serde_json::json!({ "apps": true, "psp": true }),
|
|
)]
|
|
.into_iter()
|
|
.collect(),
|
|
),
|
|
Default::default(),
|
|
)
|
|
.await?;
|
|
|
|
assert!(config.features.enabled(codex_features::Feature::Psp));
|
|
assert_eq!(
|
|
config.http_client_factory(),
|
|
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
|
|
.with_system_proxy_fallback()
|
|
.with_network_policy(config.application_network_policy.clone())
|
|
.with_chatgpt_cookies([HeaderValue::from_static("oai-chat-psp=true")])
|
|
);
|
|
assert_eq!(
|
|
config
|
|
.config_layer_stack
|
|
.effective_config()
|
|
.get("features")
|
|
.and_then(|features| features.get("psp")),
|
|
Some(&toml::Value::Boolean(true))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_missing_nested_config_is_noop() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "features.personality".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("clear missing config succeeds");
|
|
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(response.overridden_metadata, None);
|
|
assert_eq!(std::fs::read_to_string(&path)?, "");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clearing_user_setting_falls_back_to_packaged_default_without_override() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "hide_agent_reasoning = true\n")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "hide_agent_reasoning".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(response.overridden_metadata, None);
|
|
assert_eq!(std::fs::read_to_string(&path)?, "");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_legacy_profile_selector() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "model = \"gpt-main\"\n")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "profile".to_string(),
|
|
value: serde_json::json!("work"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("legacy profile selector write should fail");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("`profile` is a legacy config selector"),
|
|
"{error}"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_legacy_profile_table() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "profiles.work.model".to_string(),
|
|
value: serde_json::json!("gpt-work"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("legacy profile table write should fail");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("`profiles` contains legacy config profile tables"),
|
|
"{error}"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&path)?, "");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn batch_write_rejects_legacy_profile_selector() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "model = \"gpt-main\"\n")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let error = service
|
|
.batch_write(ConfigBatchWriteParams {
|
|
edits: vec![
|
|
codex_app_server_protocol::ConfigEdit {
|
|
key_path: "model".to_string(),
|
|
value: serde_json::json!("gpt-work"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
codex_app_server_protocol::ConfigEdit {
|
|
key_path: "profile".to_string(),
|
|
value: serde_json::json!("work"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
],
|
|
file_path: Some(path.display().to_string()),
|
|
expected_version: None,
|
|
reload_user_config: false,
|
|
})
|
|
.await
|
|
.expect_err("legacy profile selector batch write should fail");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("`profile` is a legacy config selector"),
|
|
"{error}"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_supports_nested_app_paths() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "apps".to_string(),
|
|
value: serde_json::json!({
|
|
"app1": {
|
|
"enabled": false,
|
|
"omit_tools_from": ["deferred"],
|
|
},
|
|
}),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write apps succeeds");
|
|
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "apps.app1.default_tools_approval_mode".to_string(),
|
|
value: serde_json::json!("prompt"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write apps.app1.default_tools_approval_mode succeeds");
|
|
|
|
let read = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read succeeds");
|
|
|
|
assert_eq!(
|
|
read.config.apps,
|
|
Some(AppsConfig {
|
|
default: None,
|
|
apps: std::collections::HashMap::from([(
|
|
"app1".to_string(),
|
|
AppConfig {
|
|
enabled: false,
|
|
omit_tools_from: Some(vec![
|
|
codex_protocol::config_types::ToolExposureSurface::Deferred
|
|
]),
|
|
approvals_reviewer: None,
|
|
destructive_enabled: None,
|
|
open_world_enabled: None,
|
|
default_tools_approval_mode: Some(AppToolApproval::Prompt),
|
|
default_tools_enabled: None,
|
|
tools: None,
|
|
links: None,
|
|
},
|
|
)]),
|
|
})
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_supports_custom_mcp_server_default_tool_approval_mode() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
|
|
)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "mcp_servers.docs.default_tools_approval_mode".to_string(),
|
|
value: serde_json::json!("approve"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write mcp server default_tools_approval_mode succeeds");
|
|
|
|
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?;
|
|
assert!(contents.contains("default_tools_approval_mode = \"approve\""));
|
|
|
|
let read = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read succeeds");
|
|
|
|
assert_eq!(
|
|
read.config
|
|
.additional
|
|
.get("mcp_servers")
|
|
.and_then(|servers| servers.get("docs"))
|
|
.and_then(|docs| docs.get("default_tools_approval_mode")),
|
|
Some(&serde_json::json!("approve"))
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_includes_origins_and_layers() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
|
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.config.approval_policy, Some(AskForApproval::Never));
|
|
|
|
assert_eq!(
|
|
response
|
|
.origins
|
|
.get("approval_policy")
|
|
.expect("origin")
|
|
.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_file.clone()
|
|
},
|
|
);
|
|
let layers = response.layers.expect("layers present");
|
|
// Local macOS machines can surface an MDM-managed config layer at the
|
|
// top of the stack; ignore it so this test stays focused on file/user/system ordering.
|
|
let layers = if matches!(
|
|
layers.first().map(|layer| &layer.name),
|
|
Some(ApiConfigLayerSource::LegacyManagedConfigTomlFromMdm)
|
|
) {
|
|
&layers[1..]
|
|
} else {
|
|
layers.as_slice()
|
|
};
|
|
assert_eq!(layers.len(), 3, "expected three layers");
|
|
assert_eq!(
|
|
layers.first().unwrap().name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_file.clone()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
layers.get(1).unwrap().name,
|
|
ApiConfigLayerSource::User {
|
|
file: user_file.clone(),
|
|
profile: None,
|
|
}
|
|
);
|
|
assert!(matches!(
|
|
layers.get(2).unwrap().name,
|
|
ApiConfigLayerSource::System { .. }
|
|
));
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
#[tokio::test]
|
|
async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> {
|
|
use base64::Engine;
|
|
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?;
|
|
|
|
let mut loader_overrides =
|
|
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml"));
|
|
loader_overrides.managed_preferences_base64 = Some(
|
|
base64::prelude::BASE64_STANDARD.encode(
|
|
r#"
|
|
sandbox_mode = "workspace-write"
|
|
[sandbox_workspace_write]
|
|
writable_roots = ["~/code"]
|
|
"#
|
|
.as_bytes(),
|
|
),
|
|
);
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
loader_overrides,
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "model".to_string(),
|
|
value: serde_json::json!("updated"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write succeeds");
|
|
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(
|
|
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"),
|
|
"model = \"updated\"\n"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_reports_override() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
"approval_policy = \"on-request\"",
|
|
)
|
|
.unwrap();
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let result = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "approval_policy".to_string(),
|
|
value: serde_json::json!("never"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("result");
|
|
|
|
let read_after = service
|
|
.read(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("read");
|
|
assert_eq!(
|
|
read_after.config.approval_policy,
|
|
Some(AskForApproval::Never)
|
|
);
|
|
assert_eq!(
|
|
read_after
|
|
.origins
|
|
.get("approval_policy")
|
|
.expect("origin")
|
|
.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_file.clone()
|
|
}
|
|
);
|
|
assert_eq!(result.status, WriteStatus::Ok);
|
|
assert!(result.overridden_metadata.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_setup_checks_versions_and_provider_write_uses_last_write_wins() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let layers = service.load_config_layers(/*cwd*/ None).await?;
|
|
let (write, _) = service.prepare_provider_config(
|
|
&layers,
|
|
codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID,
|
|
[("aws.region", Some("us-west-2"))],
|
|
)?;
|
|
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let newer_config = "model = 'newer'";
|
|
std::fs::write(&user_path, newer_config)?;
|
|
let error = service.commit_provider_config(write).await.unwrap_err();
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigVersionConflict)
|
|
);
|
|
assert_eq!(std::fs::read_to_string(user_path)?, newer_config);
|
|
// An intervening writer changes both the requested field and an omitted sibling.
|
|
// The patch wins for the requested field and preserves the newer sibling value.
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
"model = 'before'\n[model_providers.gateway]\nname = 'Before'\nenv_key = 'BEFORE_KEY'",
|
|
)?;
|
|
let calls = std::sync::atomic::AtomicUsize::new(/*v*/ 0);
|
|
let config_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let loader = CloudConfigBundleLoader::from_getter(move || {
|
|
if calls.fetch_add(/*val*/ 1, std::sync::atomic::Ordering::SeqCst) == 1 {
|
|
std::fs::write(&config_path, "model = 'newer'\n[model_providers.gateway]\nname = 'Another writer'\nenv_key = 'OTHER_KEY'").expect("intervening config write");
|
|
}
|
|
std::future::ready(Ok(None))
|
|
});
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
loader,
|
|
);
|
|
let response = service
|
|
.model_provider_config_write(serde_json::from_value(serde_json::json!({
|
|
"settings":{"type":"customGateway","providerId":"gateway","name":"Gateway", "baseUrl":"https://gateway.example.test"}
|
|
}))?)
|
|
.await?;
|
|
assert_eq!(
|
|
serde_json::to_value(response)?,
|
|
serde_json::json!({"provider": {
|
|
"id":"gateway", "name":"Gateway", "canEdit":true,
|
|
"baseUrl":"https://gateway.example.test",
|
|
"settings":{"type":"customGateway", "auth":{"mode":"environmentVariable", "envKey":"OTHER_KEY"}}
|
|
}})
|
|
);
|
|
let saved: serde_json::Value =
|
|
toml::from_str(&std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?)?;
|
|
assert_eq!(
|
|
saved,
|
|
serde_json::json!({"model":"newer", "model_providers":{
|
|
"gateway":{"name":"Gateway", "base_url":"https://gateway.example.test", "env_key":"OTHER_KEY"}
|
|
}})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn version_conflict_rejected() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "model".to_string(),
|
|
value: serde_json::json!("gpt-5.2"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: Some("sha256:bogus".to_string()),
|
|
})
|
|
.await
|
|
.expect_err("should fail");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigVersionConflict)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_defaults_to_user_config_path() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: None,
|
|
key_path: "model".to_string(),
|
|
value: serde_json::json!("gpt-new"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write succeeds");
|
|
|
|
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert!(
|
|
contents.contains("model = \"gpt-new\""),
|
|
"config.toml should be updated even when file_path is omitted"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_provider_settings_use_the_active_profile_layer() -> Result<()> {
|
|
use codex_app_server_protocol::CustomGatewaySettingsParams;
|
|
use codex_app_server_protocol::GatewayAuth;
|
|
use codex_app_server_protocol::ModelProviderConfigWriteParams;
|
|
use codex_app_server_protocol::ModelProviderListParams;
|
|
use codex_app_server_protocol::ProviderSettings;
|
|
use codex_app_server_protocol::ProviderSettingsPatch;
|
|
|
|
let home = tempdir()?;
|
|
let profile = home.path().join("work.config.toml");
|
|
let system = home.path().join("system.toml");
|
|
std::fs::write(
|
|
&system,
|
|
"model_provider = 'gcp'\n[model_providers.gateway]\nname = 'System'\nenv_key = 'SYSTEM_KEY'",
|
|
)?;
|
|
let overrides = LoaderOverrides {
|
|
system_config_path: Some(system.clone()),
|
|
user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile)?),
|
|
user_config_profile: Some("work".parse()?),
|
|
..LoaderOverrides::without_managed_config_for_tests()
|
|
};
|
|
let service = |cli_overrides| {
|
|
ConfigManager::new_for_tests(
|
|
home.path().to_path_buf(),
|
|
cli_overrides,
|
|
overrides.clone(),
|
|
CloudConfigBundleLoader::default(),
|
|
)
|
|
};
|
|
let manager = service(vec![]);
|
|
let list = ModelProviderListParams {};
|
|
let initial = manager.model_provider_list(list.clone()).await?;
|
|
assert_eq!(initial.default_provider_id, "gcp");
|
|
assert!(initial.can_change_default);
|
|
|
|
std::fs::write(&profile, "model_provider = 'openai'")?;
|
|
let write = ModelProviderConfigWriteParams {
|
|
settings: ProviderSettingsPatch::CustomGateway(CustomGatewaySettingsParams {
|
|
name: Some("Gateway".to_owned()),
|
|
base_url: Some(Some("https://gateway.example.test".to_owned())),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
let created = manager.model_provider_config_write(write).await?;
|
|
let selected = manager.model_provider_list(list.clone()).await?;
|
|
assert_eq!(selected.default_provider_id, "openai");
|
|
assert!(selected.can_change_default);
|
|
assert!(selected.data[0].can_edit);
|
|
|
|
let write = ModelProviderConfigWriteParams {
|
|
settings: ProviderSettingsPatch::CustomGateway(CustomGatewaySettingsParams {
|
|
provider_id: Some(created.provider.id.clone()),
|
|
name: Some("Renamed".to_owned()),
|
|
..Default::default()
|
|
}),
|
|
};
|
|
manager.model_provider_config_write(write).await?;
|
|
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&profile)?)?;
|
|
assert_eq!(
|
|
saved["model_providers"][&created.provider.id]["name"].as_str(),
|
|
Some("Renamed")
|
|
);
|
|
assert!(!home.path().join(CONFIG_TOML_FILE).exists());
|
|
|
|
let inherited = |mut settings: serde_json::Value| {
|
|
settings["type"] = "customGateway".into();
|
|
settings["providerId"] = "gateway".into();
|
|
serde_json::from_value(serde_json::json!({"settings":settings}))
|
|
};
|
|
let before = std::fs::read_to_string(&profile)?;
|
|
manager
|
|
.model_provider_config_write(inherited(serde_json::json!({"baseUrl":null}))?)
|
|
.await?;
|
|
assert_eq!(std::fs::read_to_string(&profile)?, before);
|
|
|
|
manager
|
|
.model_provider_config_write(inherited(serde_json::json!({"envKey":"USER_KEY"}))?)
|
|
.await?;
|
|
let cleared = manager
|
|
.model_provider_config_write(inherited(
|
|
serde_json::json!({"name":"Work", "envKey":null}),
|
|
)?)
|
|
.await?;
|
|
assert_eq!(
|
|
cleared.provider.settings,
|
|
ProviderSettings::CustomGateway {
|
|
auth: GatewayAuth::EnvironmentVariable {
|
|
env_key: "SYSTEM_KEY".to_owned()
|
|
}
|
|
}
|
|
);
|
|
let saved: serde_json::Value = toml::from_str(&std::fs::read_to_string(&profile)?)?;
|
|
assert_eq!(
|
|
saved["model_providers"]["gateway"],
|
|
serde_json::json!({"name":"Work"})
|
|
);
|
|
let pinned_manager = service(vec![(
|
|
"model_providers.gateway.env_key".to_owned(),
|
|
"CLI_KEY".into(),
|
|
)]);
|
|
let before = std::fs::read_to_string(&profile)?;
|
|
let err = pinned_manager
|
|
.model_provider_config_write(inherited(
|
|
serde_json::json!({"name":"Must not save", "envKey":"USER_KEY"}),
|
|
)?)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(
|
|
err.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigLayerReadonly)
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&profile)?, before);
|
|
let renamed = pinned_manager
|
|
.model_provider_config_write(inherited(serde_json::json!({"name":"Allowed"}))?)
|
|
.await?;
|
|
assert_eq!(
|
|
(renamed.provider.name.as_str(), renamed.provider.settings),
|
|
(
|
|
"Allowed",
|
|
ProviderSettings::CustomGateway {
|
|
auth: GatewayAuth::EnvironmentVariable {
|
|
env_key: "CLI_KEY".to_owned()
|
|
}
|
|
}
|
|
)
|
|
);
|
|
|
|
let pinned = service(vec![("model_provider".to_owned(), "gcp".into())])
|
|
.model_provider_list(list)
|
|
.await?;
|
|
assert_eq!(pinned.default_provider_id, "gcp");
|
|
assert!(!pinned.can_change_default);
|
|
Ok(())
|
|
}
|
|
|
|
#[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,
|
|
CloudConfigBundleLoader::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_managed_requirements_and_selected_user_config_path() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"gpt-main\"").unwrap();
|
|
std::fs::write(
|
|
tmp.path().join("requirements.toml"),
|
|
"allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"managed-workspace\"]\n",
|
|
)
|
|
.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,
|
|
CloudConfigBundleLoader::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)
|
|
);
|
|
assert_eq!(
|
|
config
|
|
.config_layer_stack
|
|
.requirements()
|
|
.managed_auth_policy(),
|
|
codex_config::ManagedAuthPolicy {
|
|
allowed_login_methods: Some(vec![codex_protocol::config_types::ForcedLoginMethod::Api]),
|
|
allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn managed_auth_policy_survives_unusable_requirements_file_changes() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
|
|
let requirements_path = tmp.path().join("requirements.toml");
|
|
std::fs::write(
|
|
&requirements_path,
|
|
"allowed_login_methods = [\"api\"]\nallowed_chatgpt_workspaces = [\"startup\"]\n",
|
|
)?;
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let startup = service.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
let auth_manager = codex_login::AuthManager::shared_from_config(
|
|
&startup, /*enable_codex_api_key_env*/ false,
|
|
)
|
|
.await?;
|
|
std::fs::write(
|
|
&requirements_path,
|
|
"allowed_login_methods = [\"chatgpt\"]\nallowed_chatgpt_workspaces = []\n",
|
|
)?;
|
|
for refreshed in [
|
|
service.load_latest_config(/*fallback_cwd*/ None).await?,
|
|
service
|
|
.load_latest_config_with_session_layers(&startup.config_layer_stack, &startup.cwd)
|
|
.await?,
|
|
] {
|
|
assert_eq!(refreshed.forced_login_method, None);
|
|
assert_eq!(refreshed.forced_chatgpt_workspace_id, None);
|
|
}
|
|
assert!(
|
|
auth_manager.is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Api)
|
|
);
|
|
assert!(
|
|
!auth_manager
|
|
.is_login_method_allowed(codex_protocol::config_types::ForcedLoginMethod::Chatgpt)
|
|
);
|
|
assert_eq!(
|
|
auth_manager.effective_chatgpt_workspaces(),
|
|
Some(vec!["startup".to_string()])
|
|
);
|
|
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 thread_refresh_resolves_preserved_layers_before_materialization() -> Result<()> {
|
|
use codex_core::config::ConfigOverrides;
|
|
use codex_features::Feature;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
let tmp = tempdir()?;
|
|
let selected_path = tmp.path().join("work.config.toml");
|
|
std::fs::write(&selected_path, "log_dir = \"old-logs\"\n")?;
|
|
let mut loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
|
loader_overrides.user_config_path = Some(AbsolutePathBuf::from_absolute_path(&selected_path)?);
|
|
loader_overrides.user_config_profile = Some("work".parse()?);
|
|
let service = ConfigManager::new(
|
|
tmp.path().to_path_buf(),
|
|
Vec::new(),
|
|
loader_overrides,
|
|
/*strict_config*/ true,
|
|
CloudConfigBundleLoader::default(),
|
|
Default::default(),
|
|
Arc::new(codex_config::NoopThreadConfigLoader),
|
|
);
|
|
let thread = service
|
|
.load_for_cwd(
|
|
Some(HashMap::from([(
|
|
"model_provider".to_string(),
|
|
serde_json::json!("openai"),
|
|
)])),
|
|
ConfigOverrides::default(),
|
|
Some(tmp.path().to_path_buf()),
|
|
)
|
|
.await?;
|
|
service
|
|
.extend_runtime_feature_enablement([
|
|
("secret_auth_storage".to_string(), true),
|
|
("mcp_oauth_refresh_coordination".to_string(), true),
|
|
])
|
|
.expect("set runtime features");
|
|
std::fs::write(
|
|
&selected_path,
|
|
"model_provider = \"missing-provider\"\nlog_dir = \"new-logs\"\n\
|
|
[features]\nmcp_oauth_refresh_coordination = false\n",
|
|
)?;
|
|
// A throwaway host configuration is invalid; the actual thread's preserved
|
|
// provider is valid. Refresh must materialize only the final composed layers.
|
|
service
|
|
.load_latest_config(Some(tmp.path().to_path_buf()))
|
|
.await
|
|
.expect_err("host provider is unavailable");
|
|
let refreshed = service
|
|
.load_latest_config_with_session_layers(&thread.config_layer_stack, &thread.cwd)
|
|
.await?;
|
|
assert_eq!(refreshed.model_provider_id, "openai");
|
|
assert_eq!(refreshed.cwd, thread.cwd);
|
|
assert_eq!(refreshed.log_dir, tmp.path().join("new-logs"));
|
|
assert!(refreshed.features.enabled(Feature::SecretAuthStorage));
|
|
assert!(
|
|
!refreshed
|
|
.features
|
|
.enabled(Feature::McpOAuthRefreshCoordination)
|
|
);
|
|
std::fs::write(&selected_path, "approval_policy = false\n")?;
|
|
let error = service
|
|
.load_latest_config_with_session_layers(&thread.config_layer_stack, &thread.cwd)
|
|
.await
|
|
.expect_err("invalid final config is rejected");
|
|
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
assert!(error.to_string().contains("work.config.toml"), "{error}");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_user_value_rejected_even_if_overridden_by_managed() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"").unwrap();
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "approval_policy".to_string(),
|
|
value: serde_json::json!("bogus"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("should fail validation");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
|
|
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents.trim(), "model = \"user\"");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reserved_builtin_provider_override_rejected() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n").unwrap();
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "model_providers.openai.name".to_string(),
|
|
value: serde_json::json!("OpenAI Override"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("should reject reserved provider override");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(error.to_string().contains("reserved built-in provider IDs"));
|
|
assert!(error.to_string().contains("`openai`"));
|
|
|
|
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config");
|
|
assert_eq!(contents, "model = \"user\"\n");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_invalid_guardian_review_threshold() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let initial = "[features.guardianv2]\nenabled = true\nreview_threshold = 0.8\n";
|
|
std::fs::write(&path, initial)?;
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "features.guardianv2.review_threshold".to_string(),
|
|
value: serde_json::json!(2.0),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("Guardian review thresholds above 1.0 must be rejected");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&path)?, initial);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_feature_requirement_conflict() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"
|
|
[features]
|
|
fast_mode = true
|
|
"#,
|
|
),
|
|
);
|
|
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "features.fast_mode".to_string(),
|
|
value: serde_json::json!(false),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("conflicting feature write should fail");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("invalid value for `features`: `features.fast_mode=false`"),
|
|
"{error}"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(),
|
|
""
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_exact_managed_requirement() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "allow_login_shell = true\n").unwrap();
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement("allow_login_shell = false"),
|
|
);
|
|
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "allow_login_shell".to_string(),
|
|
value: serde_json::json!(true),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("managed exact field should be read-only");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigRequirementReadonly)
|
|
);
|
|
assert!(error.to_string().contains("`allow_login_shell`"));
|
|
assert_eq!(
|
|
std::fs::read_to_string(path).unwrap(),
|
|
"allow_login_shell = true\n"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_rejects_managed_otel_values_and_preserves_unmanaged_siblings() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "").expect("write user config");
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"[otel]
|
|
tool_result = { max_bytes = 1024 }
|
|
log_user_prompt = false
|
|
log_agent_responses = true
|
|
log_guardian_assessments = false
|
|
exporter = "none"
|
|
trace_exporter = "none"
|
|
metrics_exporter = "none"
|
|
span_attributes = { "service.name" = "managed-service" }
|
|
tracestate = { managed = { tenant = "enterprise" }, empty = {} }"#,
|
|
),
|
|
);
|
|
for (key_path, managed) in [
|
|
("otel", true),
|
|
("otel.log_user_prompt", true),
|
|
("otel.log_agent_responses", true),
|
|
("otel.log_guardian_assessments", true),
|
|
("otel.exporter.otlp-http.endpoint", true),
|
|
("otel.trace_exporter.otlp-grpc.endpoint", true),
|
|
("otel.metrics_exporter.otlp-http.headers", true),
|
|
("otel.span_attributes.\"service.name\"", true),
|
|
("otel.tracestate.managed.tenant", true),
|
|
("otel.environment", false),
|
|
("otel.tool_result.max_bytes", true),
|
|
("otel.span_attributes.\"service.version\"", false),
|
|
("otel.tracestate.local.tenant", false),
|
|
("otel.tracestate.managed.region", false),
|
|
("otel.tracestate.empty.tenant", false),
|
|
] {
|
|
let result = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: key_path.to_string(),
|
|
value: serde_json::json!("user"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await;
|
|
assert_eq!(
|
|
result.err().and_then(|error| error.write_error_code()),
|
|
managed.then_some(ConfigWriteErrorCode::ConfigRequirementReadonly),
|
|
"OTEL path {key_path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn toml_path(tmp: &Path, name: &str) -> String {
|
|
tmp.join(name).to_string_lossy().replace('\\', "\\\\")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_omits_origins_for_exact_managed_values() {
|
|
for has_user_values in [true, false] {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_config = if has_user_values {
|
|
format!(
|
|
r#"model = "user-model"
|
|
sqlite_home = "{}"
|
|
allow_login_shell = true
|
|
|
|
[feedback]
|
|
enabled = true
|
|
"#,
|
|
toml_path(tmp.path(), "user-sqlite"),
|
|
)
|
|
} else {
|
|
"model = \"user-model\"\n".to_string()
|
|
};
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), user_config).unwrap();
|
|
|
|
let requirements = format!(
|
|
r#"sqlite_home = "{}"
|
|
allow_login_shell = false
|
|
|
|
[feedback]
|
|
enabled = false
|
|
"#,
|
|
toml_path(tmp.path(), "managed-sqlite"),
|
|
);
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements),
|
|
);
|
|
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read should succeed");
|
|
|
|
assert_eq!(
|
|
response.config.additional.get("sqlite_home"),
|
|
Some(&serde_json::json!(tmp.path().join("managed-sqlite")))
|
|
);
|
|
assert_eq!(
|
|
response.config.additional.get("allow_login_shell"),
|
|
Some(&serde_json::json!(false))
|
|
);
|
|
assert_eq!(
|
|
response.config.additional.get("feedback"),
|
|
Some(&serde_json::json!({"enabled": false}))
|
|
);
|
|
for path in ["sqlite_home", "allow_login_shell", "feedback.enabled"] {
|
|
assert!(!response.origins.contains_key(path), "origin for {path}");
|
|
}
|
|
assert!(response.origins.contains_key("model"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_projects_managed_otel_values_and_preserves_unmanaged_settings() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[otel]
|
|
tool_result = { max_bytes = 1234 }
|
|
log_user_prompt = true
|
|
environment = "user"
|
|
exporter = "statsig"
|
|
span_attributes = { "service.name" = "user-service", "service.version" = "user-version" }
|
|
tracestate = { managed = { tenant = "user-tenant", region = "us" }, local = { tenant = "preserved" }, empty = { tenant = "also-preserved" } }"#,
|
|
)
|
|
.expect("write user config");
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"[otel]
|
|
log_user_prompt = false
|
|
exporter = "none"
|
|
span_attributes = { "service.name" = "managed-service" }
|
|
tracestate = { managed = { tenant = "enterprise" }, empty = {} }"#,
|
|
),
|
|
);
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read should succeed");
|
|
let otel = response
|
|
.config
|
|
.additional
|
|
.get("otel")
|
|
.expect("effective OTEL config");
|
|
assert_eq!(
|
|
otel,
|
|
&serde_json::json!({
|
|
"tool_result": { "max_bytes": 1234 },
|
|
"log_sse_deltas": null,
|
|
"log_user_prompt": false,
|
|
"log_agent_responses": null,
|
|
"log_guardian_assessments": null,
|
|
"environment": "user",
|
|
"exporter": "none",
|
|
"trace_exporter": null,
|
|
"metrics_exporter": null,
|
|
"span_attributes": {
|
|
"service.name": "managed-service",
|
|
"service.version": "user-version",
|
|
},
|
|
"tracestate": {
|
|
"managed": { "tenant": "enterprise", "region": "us" },
|
|
"local": { "tenant": "preserved" },
|
|
"empty": { "tenant": "also-preserved" },
|
|
},
|
|
}),
|
|
);
|
|
for (path, user_owned) in [
|
|
("otel.log_user_prompt", false),
|
|
("otel.exporter", false),
|
|
("otel.span_attributes.service.name", false),
|
|
("otel.tracestate.managed.tenant", false),
|
|
("otel.tracestate.managed.region", true),
|
|
("otel.environment", true),
|
|
("otel.tool_result.max_bytes", true),
|
|
("otel.span_attributes.service.version", true),
|
|
("otel.tracestate.local.tenant", true),
|
|
("otel.tracestate.empty.tenant", true),
|
|
] {
|
|
assert_eq!(response.origins.contains_key(path), user_owned, "{path}");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_projects_managed_otel_tool_result_and_omits_its_origin() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[otel]
|
|
tool_result = { max_bytes = 1234 }"#,
|
|
)
|
|
.expect("write user config");
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"[otel]
|
|
tool_result = { max_bytes = 4096 }"#,
|
|
),
|
|
);
|
|
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read should succeed");
|
|
assert_eq!(
|
|
response
|
|
.config
|
|
.additional
|
|
.get("otel")
|
|
.and_then(|otel| otel.get("tool_result")),
|
|
Some(&serde_json::json!({ "max_bytes": 4096 }))
|
|
);
|
|
assert!(!response.origins.contains_key("otel.tool_result.max_bytes"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_keeps_omitted_otel_tool_result_unset() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(
|
|
tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[otel]
|
|
environment = "user""#,
|
|
)
|
|
.expect("write user config");
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read should succeed");
|
|
|
|
assert_eq!(
|
|
response
|
|
.config
|
|
.additional
|
|
.get("otel")
|
|
.and_then(|otel| otel.get("tool_result")),
|
|
Some(&serde_json::Value::Null)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_materializes_default_allow_login_shell() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("config read should succeed");
|
|
|
|
assert_eq!(
|
|
response.config.additional.get("allow_login_shell"),
|
|
Some(&serde_json::json!(true))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_allows_unmanaged_setting_with_exact_requirement() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, "").unwrap();
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(
|
|
r#"
|
|
allow_login_shell = false
|
|
"#,
|
|
),
|
|
);
|
|
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "windows.sandbox".to_string(),
|
|
value: serde_json::json!("elevated"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("unmanaged setting should remain writable");
|
|
|
|
assert!(
|
|
std::fs::read_to_string(path)
|
|
.unwrap()
|
|
.contains("sandbox = \"elevated\"")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_reports_managed_overrides_user_and_session_flags() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&user_path, "model = \"user\"").unwrap();
|
|
let user_file = AbsolutePathBuf::try_from(user_path.clone()).expect("user file");
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, "model = \"system\"").unwrap();
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
|
|
|
let cli_overrides = vec![(
|
|
"model".to_string(),
|
|
TomlValue::String("session".to_string()),
|
|
)];
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
cli_overrides,
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("response");
|
|
|
|
assert_eq!(response.config.model.as_deref(), Some("system"));
|
|
assert_eq!(
|
|
response.origins.get("model").expect("origin").name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_file.clone()
|
|
},
|
|
);
|
|
let layers = response.layers.expect("layers");
|
|
// Local macOS machines can surface an MDM-managed config layer at the
|
|
// top of the stack; ignore it so this test stays focused on file/session/user ordering.
|
|
let layers = if matches!(
|
|
layers.first().map(|layer| &layer.name),
|
|
Some(ApiConfigLayerSource::LegacyManagedConfigTomlFromMdm)
|
|
) {
|
|
&layers[1..]
|
|
} else {
|
|
layers.as_slice()
|
|
};
|
|
assert_eq!(
|
|
layers.first().unwrap().name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
|
|
);
|
|
assert_eq!(
|
|
layers.get(1).unwrap().name,
|
|
ApiConfigLayerSource::SessionFlags
|
|
);
|
|
assert_eq!(
|
|
layers.get(2).unwrap().name,
|
|
ApiConfigLayerSource::User {
|
|
file: user_file,
|
|
profile: None
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_value_reports_managed_override() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap();
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
|
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let result = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
|
key_path: "approval_policy".to_string(),
|
|
value: serde_json::json!("on-request"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("result");
|
|
|
|
assert_eq!(result.status, WriteStatus::OkOverridden);
|
|
let overridden = result.overridden_metadata.expect("overridden metadata");
|
|
assert_eq!(
|
|
overridden.overriding_layer.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
|
|
);
|
|
assert_eq!(overridden.effective_value, serde_json::json!("never"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn write_overrides_redact_managed_otel_exporter_headers() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&user_path, "").expect("write user config");
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(
|
|
&managed_path,
|
|
r#"[otel]
|
|
environment = "managed"
|
|
exporter = { otlp-http = { endpoint = "https://collector.example", protocol = "json", headers = { Authorization = "admin-secret", "x.api.key" = "dotted-secret" } } }
|
|
trace_exporter = { otlp-grpc = { endpoint = "https://trace.example", headers = { Authorization = "trace-secret" } } }
|
|
metrics_exporter = { otlp-http = { endpoint = "https://metrics.example", protocol = "json", headers = { Authorization = "metrics-secret" } } }
|
|
span_attributes = { "service.name" = "managed-service" }"#,
|
|
)
|
|
.expect("write managed config");
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let exporter = serde_json::json!({
|
|
"otlp-http": {
|
|
"endpoint": "https://collector.example",
|
|
"headers": { "Authorization": "[redacted]", "x.api.key": "[redacted]" },
|
|
"protocol": "json"
|
|
}
|
|
});
|
|
let otel = serde_json::json!({
|
|
"environment": "managed",
|
|
"exporter": exporter.clone(),
|
|
"trace_exporter": {
|
|
"otlp-grpc": {
|
|
"endpoint": "https://trace.example",
|
|
"headers": { "Authorization": "[redacted]" }
|
|
}
|
|
},
|
|
"metrics_exporter": {
|
|
"otlp-http": {
|
|
"endpoint": "https://metrics.example",
|
|
"protocol": "json",
|
|
"headers": { "Authorization": "[redacted]" }
|
|
}
|
|
},
|
|
"span_attributes": { "service.name": "managed-service" }
|
|
});
|
|
|
|
for (key_path, expected) in [
|
|
("otel", otel.clone()),
|
|
("otel.exporter", exporter.clone()),
|
|
("otel.exporter.otlp-http", exporter["otlp-http"].clone()),
|
|
(
|
|
"otel.exporter.otlp-http.headers",
|
|
exporter["otlp-http"]["headers"].clone(),
|
|
),
|
|
(
|
|
"otel.exporter.otlp-http.headers.Authorization",
|
|
serde_json::json!("[redacted]"),
|
|
),
|
|
(
|
|
"otel.exporter.otlp-http.headers.\"x.api.key\"",
|
|
serde_json::json!("[redacted]"),
|
|
),
|
|
(
|
|
"otel.exporter.otlp-http.endpoint",
|
|
serde_json::json!("https://collector.example"),
|
|
),
|
|
(
|
|
"otel.exporter.otlp-http.protocol",
|
|
serde_json::json!("json"),
|
|
),
|
|
("otel.trace_exporter", otel["trace_exporter"].clone()),
|
|
("otel.metrics_exporter", otel["metrics_exporter"].clone()),
|
|
(
|
|
"otel.metrics_exporter.otlp-http.headers.Authorization",
|
|
serde_json::json!("[redacted]"),
|
|
),
|
|
("otel.environment", serde_json::json!("managed")),
|
|
(
|
|
"otel.span_attributes.\"service.name\"",
|
|
serde_json::json!("managed-service"),
|
|
),
|
|
] {
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: None,
|
|
key_path: key_path.to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("no-op config clear should succeed");
|
|
assert_eq!(response.status, WriteStatus::OkOverridden, "{key_path}");
|
|
assert_eq!(
|
|
response
|
|
.overridden_metadata
|
|
.expect("managed override")
|
|
.effective_value,
|
|
expected,
|
|
"{key_path}"
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
|
|
}
|
|
|
|
let response = service
|
|
.batch_write(ConfigBatchWriteParams {
|
|
edits: vec![codex_app_server_protocol::ConfigEdit {
|
|
key_path: "otel.exporter.otlp-http.headers.Authorization".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
}],
|
|
file_path: None,
|
|
expected_version: None,
|
|
reload_user_config: false,
|
|
})
|
|
.await
|
|
.expect("no-op batch clear should succeed");
|
|
assert_eq!(response.status, WriteStatus::OkOverridden);
|
|
assert_eq!(
|
|
response
|
|
.overridden_metadata
|
|
.expect("managed batch override")
|
|
.effective_value,
|
|
serde_json::json!("[redacted]")
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&user_path).unwrap(), "");
|
|
|
|
let read = service
|
|
.read(ConfigReadParams {
|
|
include_layers: true,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("read public config");
|
|
let mut effective_exporter = exporter.clone();
|
|
effective_exporter["otlp-http"]["tls"] = serde_json::Value::Null;
|
|
assert_eq!(
|
|
read.config.additional["otel"]["exporter"],
|
|
effective_exporter
|
|
);
|
|
let managed_layer = read
|
|
.layers
|
|
.expect("config layers")
|
|
.into_iter()
|
|
.find(|layer| {
|
|
matches!(
|
|
layer.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
|
)
|
|
})
|
|
.expect("legacy managed layer");
|
|
assert_eq!(managed_layer.config["otel"]["exporter"], exporter);
|
|
|
|
let runtime = service
|
|
.load_latest_config(/*fallback_cwd*/ None)
|
|
.await
|
|
.expect("load runtime config");
|
|
let OtelExporterKind::OtlpHttp { headers, .. } = runtime.otel.exporter else {
|
|
panic!("unexpected runtime exporter");
|
|
};
|
|
assert_eq!(
|
|
headers.get("Authorization").map(|value| value.as_str()),
|
|
Some("admin-secret")
|
|
);
|
|
assert_eq!(
|
|
headers.get("x.api.key").map(|value| value.as_str()),
|
|
Some("dotted-secret")
|
|
);
|
|
}
|
|
|
|
/// Legacy managed feature toggles own their normalized enabled origin and override metadata.
|
|
#[tokio::test]
|
|
async fn multi_agent_v2_boolean_layer_owns_enabled_origin_and_overrides() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&user_path,
|
|
"[features.multi_agent_v2]\nenabled = true\nsubagent_usage_hint_text = \"keep\"\n\n[features.network_proxy]\nenabled = true\ncredential_broker = true\n",
|
|
)
|
|
.expect("user config");
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(
|
|
&managed_path,
|
|
"[features]\nmulti_agent_v2 = false\nnetwork_proxy = false\n",
|
|
)
|
|
.expect("managed config");
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone()).expect("managed file");
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let read = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.expect("read config");
|
|
for path in [
|
|
"features.multi_agent_v2.enabled",
|
|
"features.network_proxy",
|
|
"features.network_proxy.enabled",
|
|
] {
|
|
assert_eq!(
|
|
read.origins.get(path).expect("managed feature origin").name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile {
|
|
file: managed_file.clone(),
|
|
},
|
|
);
|
|
}
|
|
|
|
let result = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(user_path.display().to_string()),
|
|
key_path: "features.multi_agent_v2.enabled".to_string(),
|
|
value: serde_json::json!(true),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("write config");
|
|
assert_eq!(result.status, WriteStatus::OkOverridden);
|
|
let overridden = result.overridden_metadata.expect("overridden metadata");
|
|
assert_eq!(
|
|
overridden.overriding_layer.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
|
|
);
|
|
assert_eq!(overridden.effective_value, serde_json::json!(false));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn structured_feature_toggle_ignores_unrelated_managed_settings() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let user_path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&user_path,
|
|
"[features.network_proxy]\nenabled = false\ncredential_broker = true\n",
|
|
)?;
|
|
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(
|
|
&managed_path,
|
|
"[features.network_proxy]\nproxy_url = 'http://127.0.0.1:4321'\n",
|
|
)?;
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path.clone()),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(user_path.display().to_string()),
|
|
key_path: "features.network_proxy".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
toml::from_str::<TomlValue>(&std::fs::read_to_string(&user_path)?)?,
|
|
toml::from_str::<TomlValue>(
|
|
"[features.network_proxy]\nenabled = false\ncredential_broker = true\n"
|
|
)?
|
|
);
|
|
|
|
let result = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(user_path.display().to_string()),
|
|
key_path: "features.network_proxy".to_string(),
|
|
value: serde_json::json!(true),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(result.status, WriteStatus::Ok);
|
|
assert_eq!(result.overridden_metadata, None);
|
|
|
|
let selected_path = tmp.path().join("work.config.toml");
|
|
std::fs::write(&selected_path, "")?;
|
|
let mut loader_overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_path);
|
|
loader_overrides.user_config_path = Some(AbsolutePathBuf::from_absolute_path(&selected_path)?);
|
|
loader_overrides.user_config_profile = Some("work".parse()?);
|
|
let profile_service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
loader_overrides,
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
profile_service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(selected_path.display().to_string()),
|
|
key_path: "features.network_proxy".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(
|
|
toml::from_str::<TomlValue>(&std::fs::read_to_string(&selected_path)?)?,
|
|
toml::from_str::<TomlValue>("[features]\nnetwork_proxy = false\n")?
|
|
);
|
|
|
|
service
|
|
.batch_write(ConfigBatchWriteParams {
|
|
edits: vec![
|
|
codex_app_server_protocol::ConfigEdit {
|
|
key_path: "features.network_proxy.credential_broker".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
codex_app_server_protocol::ConfigEdit {
|
|
key_path: "features.network_proxy".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
},
|
|
],
|
|
file_path: Some(user_path.display().to_string()),
|
|
expected_version: None,
|
|
reload_user_config: false,
|
|
})
|
|
.await?;
|
|
assert!(
|
|
toml::from_str::<TomlValue>(&std::fs::read_to_string(&user_path)?)?
|
|
.get("features")
|
|
.and_then(|features| features.get("network_proxy"))
|
|
.is_none()
|
|
);
|
|
|
|
let configured_provider = "[features.network_proxy]\nenabled = true\n\n\
|
|
[features.network_proxy.credentials.vendor]\n\
|
|
env = ['VENDOR_TOKEN']\n\
|
|
patterns = ['token_[a-z]{24}']\n\
|
|
url_prefixes = ['https://api.vendor.example']\n";
|
|
std::fs::write(&user_path, configured_provider)?;
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(user_path.display().to_string()),
|
|
key_path: "features.network_proxy".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
let updated: TomlValue = toml::from_str(&std::fs::read_to_string(&user_path)?)?;
|
|
let feature = &updated["features"]["network_proxy"];
|
|
assert_eq!(
|
|
feature.get("enabled").and_then(TomlValue::as_bool),
|
|
Some(false)
|
|
);
|
|
assert!(feature.get("credentials").is_some());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_merges_tables_replace_overwrites() -> Result<()> {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let base = r#"[mcp_servers.linear]
|
|
bearer_token_env_var = "TOKEN"
|
|
name = "linear"
|
|
url = "https://linear.example"
|
|
|
|
[mcp_servers.linear.env_http_headers]
|
|
existing = "keep"
|
|
|
|
[mcp_servers.linear.http_headers]
|
|
alpha = "a"
|
|
"#;
|
|
|
|
let overlay = serde_json::json!({
|
|
"bearer_token_env_var": "NEW_TOKEN",
|
|
"http_headers": {
|
|
"alpha": "updated",
|
|
"beta": "b"
|
|
},
|
|
"name": "linear",
|
|
"url": "https://linear.example"
|
|
});
|
|
|
|
std::fs::write(&path, base)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "mcp_servers.linear".to_string(),
|
|
value: overlay.clone(),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("upsert succeeds");
|
|
|
|
let upserted: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?;
|
|
let expected_upsert: TomlValue = toml::from_str(
|
|
r#"[mcp_servers.linear]
|
|
bearer_token_env_var = "NEW_TOKEN"
|
|
name = "linear"
|
|
url = "https://linear.example"
|
|
|
|
[mcp_servers.linear.env_http_headers]
|
|
existing = "keep"
|
|
|
|
[mcp_servers.linear.http_headers]
|
|
alpha = "updated"
|
|
beta = "b"
|
|
"#,
|
|
)?;
|
|
assert_eq!(upserted, expected_upsert);
|
|
|
|
std::fs::write(&path, base)?;
|
|
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "mcp_servers.linear".to_string(),
|
|
value: overlay,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("replace succeeds");
|
|
|
|
let replaced: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?;
|
|
let expected_replace: TomlValue = toml::from_str(
|
|
r#"[mcp_servers.linear]
|
|
bearer_token_env_var = "NEW_TOKEN"
|
|
name = "linear"
|
|
url = "https://linear.example"
|
|
|
|
[mcp_servers.linear.http_headers]
|
|
alpha = "updated"
|
|
beta = "b"
|
|
"#,
|
|
)?;
|
|
assert_eq!(replaced, expected_replace);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_writes_apply_path_sensitive_merge_rules() -> Result<()> {
|
|
let cases = [
|
|
(
|
|
r#"[shell_environment_policy]
|
|
exclude = ["AWS_*"]
|
|
"#,
|
|
"shell_environment_policy",
|
|
serde_json::json!({"filters": {"AWS_*": "include"}}),
|
|
r#"[shell_environment_policy.filters]
|
|
"AWS_*" = "include"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy]
|
|
inherit = "core"
|
|
exclude = ["AWS_*"]
|
|
"#,
|
|
"shell_environment_policy.filters",
|
|
serde_json::json!({"AWS_*": "include"}),
|
|
r#"[shell_environment_policy]
|
|
inherit = "core"
|
|
|
|
[shell_environment_policy.filters]
|
|
"AWS_*" = "include"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"AWS_*" = "include"
|
|
"#,
|
|
"shell_environment_policy.exclude",
|
|
serde_json::json!(["AWS_*"]),
|
|
r#"[shell_environment_policy]
|
|
exclude = ["AWS_*"]
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy]
|
|
exclude = ["AWS_*"]
|
|
include_only = ["PATH"]
|
|
"#,
|
|
"shell_environment_policy.filters",
|
|
serde_json::json!({}),
|
|
r#"[shell_environment_policy.filters]
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"AWS_*" = "include"
|
|
"#,
|
|
"shell_environment_policy.exclude",
|
|
serde_json::json!([]),
|
|
r#"[shell_environment_policy]
|
|
exclude = []
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"aws_*" = "exclude"
|
|
"#,
|
|
"shell_environment_policy.filters",
|
|
serde_json::json!({"AWS_*": "include"}),
|
|
r#"[shell_environment_policy.filters]
|
|
"aws_*" = "include"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"aws_*" = "exclude"
|
|
"#,
|
|
"shell_environment_policy.filters.AWS_*",
|
|
serde_json::json!("include"),
|
|
r#"[shell_environment_policy.filters]
|
|
"aws_*" = "include"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"секрет_*" = "exclude"
|
|
"#,
|
|
"shell_environment_policy.filters.СЕКРЕТ_*",
|
|
serde_json::json!("include"),
|
|
r#"[shell_environment_policy.filters]
|
|
"секрет_*" = "include"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[permissions.dev.network.domains]
|
|
"example.com" = "deny"
|
|
"#,
|
|
"permissions.dev.network.domains",
|
|
serde_json::json!({"EXAMPLE.COM": "allow"}),
|
|
r#"[permissions.dev.network.domains]
|
|
"example.com" = "allow"
|
|
"#,
|
|
),
|
|
(
|
|
r#"[memories]
|
|
no_memories_if_mcp_or_web_search = false
|
|
"#,
|
|
"memories",
|
|
serde_json::json!({"disable_on_external_context": true}),
|
|
r#"[memories]
|
|
disable_on_external_context = true
|
|
"#,
|
|
),
|
|
(
|
|
r#"[features]
|
|
multi_agent_v2 = true
|
|
"#,
|
|
"features.multi_agent_v2.subagent_usage_hint_text",
|
|
serde_json::json!("Delegate carefully."),
|
|
r#"[features.multi_agent_v2]
|
|
enabled = true
|
|
subagent_usage_hint_text = "Delegate carefully."
|
|
"#,
|
|
),
|
|
(
|
|
r#"[features]
|
|
multi_agent_v2 = true
|
|
"#,
|
|
"features.multi_agent_v2",
|
|
serde_json::json!({"subagent_usage_hint_text": "Delegate carefully."}),
|
|
r#"[features.multi_agent_v2]
|
|
enabled = true
|
|
subagent_usage_hint_text = "Delegate carefully."
|
|
"#,
|
|
),
|
|
(
|
|
r#"[features.multi_agent_v2]
|
|
enabled = true
|
|
subagent_usage_hint_text = "Delegate carefully."
|
|
"#,
|
|
"features.multi_agent_v2",
|
|
serde_json::json!(false),
|
|
r#"[features.multi_agent_v2]
|
|
enabled = false
|
|
subagent_usage_hint_text = "Delegate carefully."
|
|
"#,
|
|
),
|
|
(
|
|
r#"[features.multi_agent_v2]
|
|
enabled = true
|
|
subagent_usage_hint_text = "Delegate carefully."
|
|
"#,
|
|
"features.multi_agent_v2",
|
|
serde_json::Value::Null,
|
|
"",
|
|
),
|
|
(
|
|
r#"[features]
|
|
network_proxy = true
|
|
"#,
|
|
"features.network_proxy.credential_broker",
|
|
serde_json::json!(true),
|
|
r#"[features.network_proxy]
|
|
enabled = true
|
|
credential_broker = true
|
|
"#,
|
|
),
|
|
(
|
|
r#"[features.network_proxy]
|
|
enabled = true
|
|
credential_broker = true
|
|
"#,
|
|
"features.network_proxy",
|
|
serde_json::json!(false),
|
|
r#"[features.network_proxy]
|
|
enabled = false
|
|
credential_broker = true
|
|
"#,
|
|
),
|
|
(
|
|
r#"[desktop.features.multi_agent_v2]
|
|
custom = true
|
|
"#,
|
|
"desktop.features.multi_agent_v2",
|
|
serde_json::json!(false),
|
|
r#"[desktop.features]
|
|
multi_agent_v2 = false
|
|
"#,
|
|
),
|
|
(
|
|
r#"[desktop.features]
|
|
multi_agent_v2 = true
|
|
"#,
|
|
"desktop.features.multi_agent_v2",
|
|
serde_json::json!({"custom": true}),
|
|
r#"[desktop.features.multi_agent_v2]
|
|
custom = true
|
|
"#,
|
|
),
|
|
];
|
|
|
|
for (base, key_path, value, expected) in cases {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(&path, base)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: key_path.to_string(),
|
|
value,
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
let updated: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?;
|
|
let expected: TomlValue = toml::from_str(expected)?;
|
|
assert_eq!(updated, expected);
|
|
|
|
service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn clear_shell_environment_filter_ignores_ascii_case() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&path,
|
|
r#"[shell_environment_policy.filters]
|
|
"aws_*" = "exclude"
|
|
"keep_*" = "include"
|
|
"#,
|
|
)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.filters.AWS_*".to_string(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(response.overridden_metadata, None);
|
|
assert_eq!(
|
|
std::fs::read_to_string(&path)?,
|
|
r#"[shell_environment_policy.filters]
|
|
"keep_*" = "include"
|
|
"#
|
|
);
|
|
service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_shell_environment_scalar_preserves_unrelated_formatting() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&path,
|
|
r#"[shell_environment_policy]
|
|
inherit = "all"
|
|
exclude = [
|
|
"AWS_*", # keep this comment
|
|
]
|
|
set = { KEEP = "1", OTHER = "2", GH_HOST = "github.stale.example" } # keep this inline table
|
|
"#,
|
|
)?;
|
|
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.inherit".to_string(),
|
|
value: serde_json::json!("core"),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(&path)?,
|
|
r#"[shell_environment_policy]
|
|
inherit = "core"
|
|
exclude = [
|
|
"AWS_*", # keep this comment
|
|
]
|
|
set = { KEEP = "1", OTHER = "2", GH_HOST = "github.stale.example" } # keep this inline table
|
|
"#
|
|
);
|
|
for (value, expected_set) in [
|
|
(
|
|
serde_json::json!("github.trusted.example"),
|
|
"KEEP='1'\nOTHER='2'\nGH_HOST='github.stale.example'\ngh_host='github.trusted.example'",
|
|
),
|
|
(
|
|
serde_json::Value::Null,
|
|
"KEEP='1'\nOTHER='2'\nGH_HOST='github.stale.example'",
|
|
),
|
|
] {
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.set.gh_host".to_string(),
|
|
value,
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(response.overridden_metadata, None);
|
|
let config: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?;
|
|
assert_eq!(
|
|
config["shell_environment_policy"]["set"],
|
|
toml::from_str::<TomlValue>(expected_set)?
|
|
);
|
|
}
|
|
service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn upsert_shell_environment_filter_scalar_preserves_formatting_and_version() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&path,
|
|
r#"[shell_environment_policy]
|
|
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
|
|
|
[shell_environment_policy.filters]
|
|
"AWS_*" = "exclude" # keep this edited comment
|
|
"KEEP_*" = "include" # keep this untouched comment
|
|
"#,
|
|
)?;
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.filters.aws_*".to_string(),
|
|
value: serde_json::json!("include"),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
std::fs::read_to_string(&path)?,
|
|
r#"[shell_environment_policy]
|
|
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
|
|
|
[shell_environment_policy.filters]
|
|
"AWS_*" = "include" # keep this edited comment
|
|
"KEEP_*" = "include" # keep this untouched comment
|
|
"#
|
|
);
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.filters.AWS_*".to_string(),
|
|
value: serde_json::json!("exclude"),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: Some(response.version),
|
|
})
|
|
.await?;
|
|
service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shell_environment_upsert_rejects_case_variant_filters_in_one_edit() -> Result<()> {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let initial = r#"[shell_environment_policy.filters]
|
|
"KEEP_*" = "include"
|
|
"#;
|
|
std::fs::write(&path, initial)?;
|
|
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
|
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "shell_environment_policy.filters".to_string(),
|
|
value: serde_json::json!({"AWS_*": "include", "aws_*": "exclude"}),
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("one filter-map edit must not contain case-variant keys");
|
|
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigValidationError)
|
|
);
|
|
assert!(
|
|
error
|
|
.to_string()
|
|
.contains("duplicate shell environment filter")
|
|
);
|
|
assert_eq!(std::fs::read_to_string(&path)?, initial);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn shell_environment_representation_switch_reports_managed_override() -> Result<()> {
|
|
let cases = [
|
|
(
|
|
r#"[shell_environment_policy]
|
|
exclude = ["AWS_*"]
|
|
"#,
|
|
"shell_environment_policy.filters.AWS_*",
|
|
serde_json::json!("include"),
|
|
Some(serde_json::Value::Null),
|
|
),
|
|
(
|
|
r#"[shell_environment_policy.filters]
|
|
"AWS_*" = "include"
|
|
"#,
|
|
"shell_environment_policy.exclude",
|
|
serde_json::json!(["AWS_*"]),
|
|
Some(serde_json::Value::Null),
|
|
),
|
|
(
|
|
"[shell_environment_policy.set]\nGH_HOST = 'github.managed.example'\n",
|
|
"shell_environment_policy.set.GH_HOST",
|
|
serde_json::json!("github.user.example"),
|
|
Some(serde_json::json!("github.managed.example")),
|
|
),
|
|
(
|
|
"[shell_environment_policy.set]\nGH_HOST = 'github.managed.example'\n",
|
|
"shell_environment_policy.set.gh_host",
|
|
serde_json::json!("github.user.example"),
|
|
None,
|
|
),
|
|
(
|
|
"[shell_environment_policy.set]\ngh_host = 'github.managed.example'\n",
|
|
"shell_environment_policy.set.gh_host",
|
|
serde_json::json!("github.user.example"),
|
|
Some(serde_json::json!("github.managed.example")),
|
|
),
|
|
(
|
|
"[shell_environment_policy.set]\ngh_host = 'github.managed.example'\n",
|
|
"shell_environment_policy.set.GH_HOST",
|
|
serde_json::json!("github.user.example"),
|
|
None,
|
|
),
|
|
];
|
|
|
|
for (managed, key_path, value, expected_effective_value) in cases {
|
|
let tmp = tempdir()?;
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
std::fs::write(
|
|
&path,
|
|
"[shell_environment_policy.set]\n\
|
|
GH_HOST = 'github.original.example'\ngh_host = 'github.original-lower.example'\n",
|
|
)?;
|
|
let managed_path = tmp.path().join("managed_config.toml");
|
|
std::fs::write(&managed_path, managed)?;
|
|
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
|
|
let response = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: key_path.to_string(),
|
|
value,
|
|
merge_strategy: MergeStrategy::Upsert,
|
|
expected_version: None,
|
|
})
|
|
.await?;
|
|
|
|
if let Some(expected_effective_value) = expected_effective_value {
|
|
assert_eq!(response.status, WriteStatus::OkOverridden);
|
|
let overridden = response
|
|
.overridden_metadata
|
|
.expect("managed setting should override the user edit");
|
|
assert_eq!(
|
|
overridden.overriding_layer.name,
|
|
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
|
|
);
|
|
assert_eq!(overridden.effective_value, expected_effective_value);
|
|
} else {
|
|
assert_eq!(response.status, WriteStatus::Ok);
|
|
assert_eq!(response.overridden_metadata, None);
|
|
}
|
|
service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn allowed_login_methods_follow_current_forced_workspaces() -> Result<()> {
|
|
use codex_protocol::config_types::ForcedLoginMethod;
|
|
|
|
let tmp = tempdir()?;
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
|
|
std::fs::write(
|
|
tmp.path().join("requirements.toml"),
|
|
"allowed_chatgpt_workspaces = ['managed']",
|
|
)?;
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::with_managed_config_path_for_tests(tmp.path().join("managed_config.toml")),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let config = service.load_latest_config(/*fallback_cwd*/ None).await?;
|
|
let auth = codex_login::AuthManager::shared_from_config(
|
|
&config, /*enable_codex_api_key_env*/ false,
|
|
)
|
|
.await?;
|
|
for (workspaces, expected) in [
|
|
(
|
|
Some(vec!["managed".to_string()]),
|
|
vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt],
|
|
),
|
|
(
|
|
Some(vec!["other".to_string()]),
|
|
vec![ForcedLoginMethod::Api],
|
|
),
|
|
(Some(Vec::new()), vec![ForcedLoginMethod::Api]),
|
|
(
|
|
None,
|
|
vec![ForcedLoginMethod::Api, ForcedLoginMethod::Chatgpt],
|
|
),
|
|
] {
|
|
auth.set_forced_chatgpt_workspace_id(workspaces);
|
|
assert_eq!(auth.allowed_login_methods(), expected);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn permission_config_reload_merges_session_layers() -> Result<()> {
|
|
use codex_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerSource;
|
|
use codex_config::ConfigLayerStack;
|
|
let tmp = tempdir()?;
|
|
let service = ConfigManager::new(
|
|
tmp.path().to_path_buf(),
|
|
Vec::new(),
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
/*strict_config*/ false,
|
|
CloudConfigBundleLoader::default(),
|
|
codex_arg0::Arg0DispatchPaths::default(),
|
|
std::sync::Arc::new(codex_config::NoopThreadConfigLoader),
|
|
);
|
|
|
|
let mut config = service
|
|
.load_with_overrides(
|
|
/*request_overrides*/ None,
|
|
codex_core::config::ConfigOverrides {
|
|
cwd: Some(tmp.path().to_path_buf()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
let mut layers = config
|
|
.config_layer_stack
|
|
.all_layers_low_to_high()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
for contents in [
|
|
"[permissions.first.filesystem]\n",
|
|
"[permissions.second]\nextends = ':workspace'\n",
|
|
] {
|
|
layers.push(ConfigLayerEntry::new(
|
|
ConfigLayerSource::SessionFlags,
|
|
toml::from_str(contents)?,
|
|
));
|
|
}
|
|
layers.push(ConfigLayerEntry::new_disabled(
|
|
ConfigLayerSource::SessionFlags,
|
|
toml::from_str("[permissions.disabled]\nextends = ':read-only'\n")?,
|
|
"disabled for test",
|
|
));
|
|
config.config_layer_stack =
|
|
ConfigLayerStack::new(layers, Default::default(), Default::default())?;
|
|
let loaded = service
|
|
.load_permission_config_for_thread(&config, config.cwd.clone(), "first".to_string())
|
|
.await?;
|
|
assert_eq!(
|
|
loaded
|
|
.custom_permission_profiles
|
|
.iter()
|
|
.map(|profile| profile.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["first", "second"]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Cloud storage requirements override user config and reject leaf or whole-table writes without touching the file.
|
|
#[tokio::test]
|
|
async fn cloud_desktop_storage_requirements_control_reads_and_writes() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let path = tmp.path().join(CONFIG_TOML_FILE);
|
|
let original = "[desktop]\ndefaultProjectParent = 'user'\nprojectlessWorkspaceRoot = 'user'\nlocaleOverride = 'en-US'\n";
|
|
std::fs::write(&path, original).unwrap();
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
vec![],
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleFixture::loader_with_enterprise_requirement(format!(
|
|
"[application.storage]\ndefault_project_parent = \"{}\"\nprojectless_workspace_root = \"{}\"",
|
|
toml_path(tmp.path(), "projects"),
|
|
toml_path(tmp.path(), "chats"),
|
|
)),
|
|
);
|
|
let response = service
|
|
.read(ConfigReadParams {
|
|
include_layers: false,
|
|
cwd: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
serde_json::to_value(response.config.desktop).unwrap(),
|
|
serde_json::json!({
|
|
"defaultProjectParent": tmp.path().join("projects"),
|
|
"projectlessWorkspaceRoot": tmp.path().join("chats"),
|
|
"localeOverride": "en-US",
|
|
})
|
|
);
|
|
for key in [
|
|
"desktop.defaultProjectParent",
|
|
"desktop.projectlessWorkspaceRoot",
|
|
"desktop",
|
|
] {
|
|
let error = service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: key.into(),
|
|
value: serde_json::Value::Null,
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect_err("managed storage is read-only");
|
|
assert_eq!(
|
|
error.write_error_code(),
|
|
Some(ConfigWriteErrorCode::ConfigRequirementReadonly)
|
|
);
|
|
}
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
|
service
|
|
.write_value(ConfigValueWriteParams {
|
|
file_path: Some(path.display().to_string()),
|
|
key_path: "desktop.localeOverride".into(),
|
|
value: serde_json::json!("fr-FR"),
|
|
merge_strategy: MergeStrategy::Replace,
|
|
expected_version: None,
|
|
})
|
|
.await
|
|
.expect("unmanaged desktop settings stay writable");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_provider_list_allows_user_overrides_of_system_defaults() -> Result<()> {
|
|
use codex_app_server_protocol::ModelProviderListParams;
|
|
|
|
let tmp = tempdir()?;
|
|
let system_path = tmp.path().join("system.toml");
|
|
std::fs::write(
|
|
&system_path,
|
|
"model_provider = 'team'\n[model_providers.team]\nname = 'System gateway'\nbase_url = 'https://system.example.test/v1'\nenv_key = 'SYSTEM_KEY'\n",
|
|
)?;
|
|
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
|
|
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
|
|
overrides.system_config_path = Some(system_path);
|
|
for (cli_overrides, ignore_user_config, can_edit, can_change_default, name, auth_mode) in [
|
|
(
|
|
vec![],
|
|
false,
|
|
true,
|
|
true,
|
|
"System gateway",
|
|
"environmentVariable",
|
|
),
|
|
(
|
|
vec![],
|
|
true,
|
|
false,
|
|
false,
|
|
"System gateway",
|
|
"environmentVariable",
|
|
),
|
|
(
|
|
vec![("model_provider", "team")],
|
|
false,
|
|
true,
|
|
false,
|
|
"System gateway",
|
|
"environmentVariable",
|
|
),
|
|
(
|
|
vec![(
|
|
"model_providers.team.base_url",
|
|
"https://system.example.test/v1",
|
|
)],
|
|
false,
|
|
true,
|
|
true,
|
|
"System gateway",
|
|
"environmentVariable",
|
|
),
|
|
(
|
|
vec![
|
|
("model_providers.team.name", "System gateway"),
|
|
(
|
|
"model_providers.team.base_url",
|
|
"https://system.example.test/v1",
|
|
),
|
|
("model_providers.team.env_key", "SYSTEM_KEY"),
|
|
],
|
|
false,
|
|
false,
|
|
true,
|
|
"System gateway",
|
|
"environmentVariable",
|
|
),
|
|
(
|
|
vec![("model_providers.team.name", "OpenAI")],
|
|
false,
|
|
false,
|
|
true,
|
|
"OpenAI",
|
|
"externallyConfigured",
|
|
),
|
|
(
|
|
vec![("model_providers.team.name", " ")],
|
|
false,
|
|
false,
|
|
true,
|
|
" ",
|
|
"environmentVariable",
|
|
),
|
|
] {
|
|
overrides.ignore_user_config = ignore_user_config;
|
|
let cli_overrides = cli_overrides
|
|
.into_iter()
|
|
.map(|(key, value)| (key.to_owned(), TomlValue::String(value.to_owned())))
|
|
.collect();
|
|
let service = ConfigManager::new_for_tests(
|
|
tmp.path().to_path_buf(),
|
|
cli_overrides,
|
|
overrides.clone(),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let response = service
|
|
.model_provider_list(ModelProviderListParams {})
|
|
.await?;
|
|
assert_eq!(response.can_change_default, can_change_default);
|
|
assert_eq!(response.default_provider_id, "team");
|
|
assert_eq!(
|
|
serde_json::to_value(response.data)?,
|
|
serde_json::json!([
|
|
{"id": "openai", "name": "OpenAI", "canEdit": false, "baseUrl":null, "settings":{"type":"openai"}},
|
|
{
|
|
"id": "team", "name": name,
|
|
"canEdit": can_edit, "baseUrl": "https://system.example.test/v1",
|
|
"settings": {"type": "customGateway", "auth": {"mode": auth_mode, "envKey": "SYSTEM_KEY"}},
|
|
}
|
|
])
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn model_provider_list_checks_builtin_configuration_fields() -> Result<()> {
|
|
use codex_app_server_protocol::ModelProviderListParams;
|
|
use codex_app_server_protocol::ModelProviderSummary;
|
|
use codex_app_server_protocol::ProviderSettings;
|
|
|
|
let home = tempdir()?;
|
|
std::fs::write(
|
|
home.path().join(CONFIG_TOML_FILE),
|
|
"[model_providers.amazon-bedrock.aws]\nregion = 'us-east-1'\n[model_providers.amazon-bedrock-runtime.aws]\nregion = 'us-east-1'\n[model_providers.gcp]\nproject_id = 'test-project'\n",
|
|
)?;
|
|
let credential_file = home.path().join("credentials.json").display().to_string();
|
|
for (id, name, settings) in [
|
|
(
|
|
"amazon-bedrock",
|
|
"Amazon Bedrock Mantle",
|
|
&[("aws.profile", "default"), ("aws.region", "us-east-1")][..],
|
|
),
|
|
(
|
|
"amazon-bedrock-runtime",
|
|
"Amazon Bedrock Runtime",
|
|
&[("aws.profile", "default"), ("aws.region", "us-east-1")][..],
|
|
),
|
|
(
|
|
"gcp",
|
|
"Google Cloud Vertex AI",
|
|
&[
|
|
("project_id", "test-project"),
|
|
("location", "global"),
|
|
("credential_file", credential_file.as_str()),
|
|
][..],
|
|
),
|
|
] {
|
|
for pinned_fields in [1, settings.len()] {
|
|
let overrides = settings
|
|
.iter()
|
|
.take(pinned_fields)
|
|
.map(|(field, value)| {
|
|
(
|
|
format!("model_providers.{id}.{field}"),
|
|
TomlValue::String((*value).to_owned()),
|
|
)
|
|
})
|
|
.collect();
|
|
let service = ConfigManager::new_for_tests(
|
|
home.path().to_path_buf(),
|
|
overrides,
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let list = service
|
|
.model_provider_list(ModelProviderListParams {})
|
|
.await?;
|
|
assert_eq!(
|
|
list.data.into_iter().find(|provider| provider.id == id),
|
|
Some(ModelProviderSummary {
|
|
id: id.to_owned(),
|
|
name: name.to_owned(),
|
|
can_edit: pinned_fields < settings.len(),
|
|
base_url: None,
|
|
settings: if id == "gcp" {
|
|
ProviderSettings::Gcp {
|
|
project_id: Some("test-project".to_owned()),
|
|
location: (pinned_fields == settings.len())
|
|
.then(|| "global".to_owned()),
|
|
credential_file: (pinned_fields == settings.len())
|
|
.then(|| credential_file.clone()),
|
|
}
|
|
} else {
|
|
ProviderSettings::Bedrock {
|
|
profile: Some("default".to_owned()),
|
|
region: Some("us-east-1".to_owned()),
|
|
}
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
for id in ["amazon-bedrock", "amazon-bedrock-runtime"] {
|
|
for (section, overrides) in [
|
|
("auth", vec![]),
|
|
(
|
|
"aws.credential_export",
|
|
vec![(
|
|
format!("model_providers.{id}.aws.region"),
|
|
"us-east-1".into(),
|
|
)],
|
|
),
|
|
] {
|
|
std::fs::write(
|
|
home.path().join(CONFIG_TOML_FILE),
|
|
format!("[model_providers.{id}.{section}]\ncommand = 'helper'\n"),
|
|
)?;
|
|
let service = ConfigManager::new_for_tests(
|
|
home.path().to_path_buf(),
|
|
overrides,
|
|
LoaderOverrides::without_managed_config_for_tests(),
|
|
CloudConfigBundleLoader::default(),
|
|
);
|
|
let list = service
|
|
.model_provider_list(ModelProviderListParams {})
|
|
.await?;
|
|
assert_eq!(
|
|
list.data
|
|
.into_iter()
|
|
.find(|provider| provider.id == id)
|
|
.map(|provider| provider.can_edit),
|
|
Some(false)
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|