Add managed feature disables to requirements

This commit is contained in:
daniel-oai
2026-03-03 14:45:01 -08:00
parent 041c896509
commit f068d7d784
13 changed files with 284 additions and 9 deletions
@@ -8963,6 +8963,15 @@
"type": "null"
}
]
},
"features": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"object",
"null"
]
}
},
"type": "object"
@@ -81,6 +81,15 @@
"type": "null"
}
]
},
"features": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"object",
"null"
]
}
},
"type": "object"
@@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, features: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -611,6 +611,7 @@ pub struct ConfigRequirements {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub features: Option<HashMap<String, bool>>,
pub enforce_residency: Option<ResidencyRequirement>,
#[experimental("configRequirements/read.network")]
pub network: Option<NetworkRequirements>,
+1 -1
View File
@@ -163,7 +163,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `enforceResidency`, and `network` constraints.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), managed feature disables (`features`), `enforceResidency`, and `network` constraints.
### Example: Start or resume a thread
+11
View File
@@ -127,6 +127,9 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
normalized
}),
features: requirements
.features
.map(|features| features.entries.into_iter().collect()),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
@@ -212,6 +215,9 @@ mod tests {
allowed_web_search_modes: Some(vec![
codex_core::config_loader::WebSearchModeRequirement::Cached,
]),
features: Some(codex_core::config_loader::RequirementsFeaturesToml {
entries: [("unified_exec".to_string(), false)].into_iter().collect(),
}),
mcp_servers: None,
rules: None,
enforce_residency: Some(CoreResidencyRequirement::Us),
@@ -247,6 +253,10 @@ mod tests {
mapped.allowed_web_search_modes,
Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]),
);
assert_eq!(
mapped.features,
Some([("unified_exec".to_string(), false)].into_iter().collect())
);
assert_eq!(
mapped.enforce_residency,
Some(codex_app_server_protocol::ResidencyRequirement::Us),
@@ -275,6 +285,7 @@ mod tests {
allowed_approval_policies: None,
allowed_sandbox_modes: None,
allowed_web_search_modes: Some(Vec::new()),
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -79,6 +79,7 @@ pub struct ConfigRequirements {
pub approval_policy: ConstrainedWithSource<AskForApproval>,
pub sandbox_policy: ConstrainedWithSource<SandboxPolicy>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub features: Option<Sourced<RequirementsFeaturesToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub exec_policy: Option<Sourced<RequirementsExecPolicy>>,
pub enforce_residency: ConstrainedWithSource<Option<ResidencyRequirement>>,
@@ -101,6 +102,7 @@ impl Default for ConfigRequirements {
Constrained::allow_any(WebSearchMode::Cached),
None,
),
features: None,
mcp_servers: None,
exec_policy: None,
enforce_residency: ConstrainedWithSource::new(Constrained::allow_any(None), None),
@@ -142,6 +144,12 @@ pub struct NetworkRequirementsToml {
pub allow_local_binding: Option<bool>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct RequirementsFeaturesToml {
#[serde(flatten)]
pub entries: BTreeMap<String, bool>,
}
/// Normalized network constraints derived from requirements TOML.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NetworkConstraints {
@@ -233,6 +241,7 @@ pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub features: Option<RequirementsFeaturesToml>,
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
pub rules: Option<RequirementsExecPolicyToml>,
pub enforce_residency: Option<ResidencyRequirement>,
@@ -267,6 +276,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub features: Option<Sourced<RequirementsFeaturesToml>>,
pub mcp_servers: Option<Sourced<BTreeMap<String, McpServerRequirement>>>,
pub rules: Option<Sourced<RequirementsExecPolicyToml>>,
pub enforce_residency: Option<Sourced<ResidencyRequirement>>,
@@ -302,6 +312,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_sandbox_modes,
allowed_web_search_modes,
features,
mcp_servers,
rules,
enforce_residency,
@@ -315,6 +326,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_sandbox_modes,
allowed_web_search_modes,
features,
mcp_servers,
rules,
enforce_residency,
@@ -324,6 +336,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
features: features.map(|sourced| sourced.value),
mcp_servers: mcp_servers.map(|sourced| sourced.value),
rules: rules.map(|sourced| sourced.value),
enforce_residency: enforce_residency.map(|sourced| sourced.value),
@@ -370,6 +383,7 @@ impl ConfigRequirementsToml {
self.allowed_approval_policies.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.features.is_none()
&& self.mcp_servers.is_none()
&& self.rules.is_none()
&& self.enforce_residency.is_none()
@@ -385,6 +399,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_approval_policies,
allowed_sandbox_modes,
allowed_web_search_modes,
features,
mcp_servers,
rules,
enforce_residency,
@@ -553,6 +568,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
approval_policy,
sandbox_policy,
web_search_mode,
features,
mcp_servers,
exec_policy,
enforce_residency,
@@ -588,6 +604,7 @@ mod tests {
allowed_approval_policies,
allowed_sandbox_modes,
allowed_web_search_modes,
features,
mcp_servers,
rules,
enforce_residency,
@@ -600,6 +617,7 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
features: features.map(|value| Sourced::new(value, RequirementSource::Unknown)),
mcp_servers: mcp_servers.map(|value| Sourced::new(value, RequirementSource::Unknown)),
rules: rules.map(|value| Sourced::new(value, RequirementSource::Unknown)),
enforce_residency: enforce_residency
@@ -622,6 +640,9 @@ mod tests {
WebSearchModeRequirement::Cached,
WebSearchModeRequirement::Live,
];
let features = RequirementsFeaturesToml {
entries: BTreeMap::from([(String::from("unified_exec"), false)]),
};
let enforce_residency = ResidencyRequirement::Us;
let enforce_source = source.clone();
@@ -631,6 +652,7 @@ mod tests {
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
features: Some(features.clone()),
mcp_servers: None,
rules: None,
enforce_residency: Some(enforce_residency),
@@ -651,6 +673,7 @@ mod tests {
allowed_web_search_modes,
enforce_source.clone(),
)),
features: Some(Sourced::new(features, enforce_source.clone())),
mcp_servers: None,
rules: None,
enforce_residency: Some(Sourced::new(enforce_residency, enforce_source)),
@@ -683,6 +706,7 @@ mod tests {
)),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -723,6 +747,7 @@ mod tests {
)),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -1126,6 +1151,28 @@ mod tests {
Ok(())
}
#[test]
fn deserialize_feature_requirements() -> Result<()> {
let toml_str = r#"
[features]
unified_exec = false
"#;
let requirements: ConfigRequirements =
with_unknown_source(from_str(toml_str)?).try_into()?;
assert_eq!(
requirements.features,
Some(Sourced::new(
RequirementsFeaturesToml {
entries: BTreeMap::from([(String::from("unified_exec"), false)]),
},
RequirementSource::Unknown,
))
);
Ok(())
}
#[test]
fn deserialize_exec_policy_requirements() -> Result<()> {
let toml_str = r#"
+1
View File
@@ -21,6 +21,7 @@ pub use config_requirements::McpServerRequirement;
pub use config_requirements::NetworkConstraints;
pub use config_requirements::NetworkRequirementsToml;
pub use config_requirements::RequirementSource;
pub use config_requirements::RequirementsFeaturesToml;
pub use config_requirements::ResidencyRequirement;
pub use config_requirements::SandboxModeRequirement;
pub use config_requirements::Sourced;
+185 -1
View File
@@ -32,6 +32,7 @@ use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::McpServerIdentity;
use crate::config_loader::McpServerRequirement;
use crate::config_loader::RequirementsFeaturesToml;
use crate::config_loader::ResidencyRequirement;
use crate::config_loader::Sourced;
use crate::config_loader::load_config_layers_state;
@@ -39,6 +40,9 @@ use crate::features::Feature;
use crate::features::FeatureOverrides;
use crate::features::Features;
use crate::features::FeaturesToml;
use crate::features::Stage;
use crate::features::canonical_feature_for_alias;
use crate::features::canonical_feature_spec;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -810,6 +814,68 @@ where
Ok(())
}
fn apply_requirement_feature_constraints(
features: &mut Features,
requirement_features: Option<&Sourced<RequirementsFeaturesToml>>,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<()> {
let Some(requirement_features) = requirement_features else {
return Ok(());
};
for (key, enabled) in &requirement_features.value.entries {
if *enabled {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"requirements features can only disable features; `{key}` was set to true (set by {})",
requirement_features.source
),
));
}
let Some(spec) = canonical_feature_spec(key) else {
if let Some(feature) = canonical_feature_for_alias(key) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"requirements features uses legacy key `{key}` (set by {}); use canonical key `{}` instead",
requirement_features.source,
feature.key()
),
));
}
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"requirements features contains unknown feature key `{key}` (set by {})",
requirement_features.source
),
));
};
if matches!(spec.stage, Stage::Removed) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"requirements features contains removed feature key `{key}` (set by {})",
requirement_features.source
),
));
}
if features.enabled(spec.id) {
startup_warnings.push(format!(
"Configured value for `features.{key}` is disallowed by requirements; forcing false."
));
}
features.disable(spec.id);
}
Ok(())
}
fn mcp_server_matches_requirement(
requirement: &McpServerRequirement,
server: &McpServerConfig,
@@ -1739,7 +1805,12 @@ impl Config {
web_search_request: override_tools_web_search_request,
};
let features = Features::from_config(&cfg, &config_profile, feature_overrides);
let mut features = Features::from_config(&cfg, &config_profile, feature_overrides);
apply_requirement_feature_constraints(
&mut features,
requirements.features.as_ref(),
&mut startup_warnings,
)?;
let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile);
let resolved_cwd = {
use std::env;
@@ -2058,6 +2129,7 @@ impl Config {
approval_policy: mut constrained_approval_policy,
sandbox_policy: mut constrained_sandbox_policy,
web_search_mode: mut constrained_web_search_mode,
features: _,
mcp_servers,
exec_policy: _,
enforce_residency,
@@ -5394,6 +5466,7 @@ model_verbosity = "high"
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
]),
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -5419,6 +5492,7 @@ model_verbosity = "high"
constrained,
Some(requirement_source),
),
features: None,
..Default::default()
};
let config_layer_stack = crate::config_loader::ConfigLayerStack::new(
@@ -5998,6 +6072,7 @@ mcp_oauth_callback_url = "https://example.com/callback"
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -6232,6 +6307,115 @@ speaker = "Desk Speakers"
);
Ok(())
}
#[tokio::test]
async fn managed_feature_requirements_disable_unified_exec_with_warning() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
unified_exec = true
"#,
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
features: Some(crate::config_loader::RequirementsFeaturesToml {
entries: [("unified_exec".to_string(), false)].into_iter().collect(),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(!config.features.enabled(Feature::UnifiedExec));
assert!(!config.use_experimental_unified_exec_tool);
assert!(
config
.startup_warnings
.iter()
.any(|warning| warning.contains("features.unified_exec"))
);
Ok(())
}
#[tokio::test]
async fn managed_feature_requirements_reject_true_values() {
let codex_home = TempDir::new().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
features: Some(crate::config_loader::RequirementsFeaturesToml {
entries: [("unified_exec".to_string(), true)].into_iter().collect(),
}),
..Default::default()
}))
}))
.build()
.await
.expect_err("requirements feature true should fail");
assert!(err.to_string().contains("can only disable features"));
}
#[tokio::test]
async fn managed_feature_requirements_reject_unknown_feature_keys() {
let codex_home = TempDir::new().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
features: Some(crate::config_loader::RequirementsFeaturesToml {
entries: [("definitely_fake_feature".to_string(), false)]
.into_iter()
.collect(),
}),
..Default::default()
}))
}))
.build()
.await
.expect_err("unknown feature key should fail");
assert!(err.to_string().contains("unknown feature key"));
}
#[tokio::test]
async fn managed_feature_requirements_reject_legacy_aliases() {
let codex_home = TempDir::new().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
features: Some(crate::config_loader::RequirementsFeaturesToml {
entries: [("experimental_use_unified_exec_tool".to_string(), false)]
.into_iter()
.collect(),
}),
..Default::default()
}))
}))
.build()
.await
.expect_err("legacy alias should fail");
assert!(err.to_string().contains("legacy key"));
assert!(err.to_string().contains("unified_exec"));
}
}
#[cfg(test)]
+1
View File
@@ -40,6 +40,7 @@ pub use codex_config::McpServerRequirement;
pub use codex_config::NetworkConstraints;
pub use codex_config::NetworkRequirementsToml;
pub use codex_config::RequirementSource;
pub use codex_config::RequirementsFeaturesToml;
pub use codex_config::ResidencyRequirement;
pub use codex_config::SandboxModeRequirement;
pub use codex_config::Sourced;
+3
View File
@@ -581,6 +581,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -629,6 +630,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -666,6 +668,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
features: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
+9 -1
View File
@@ -402,7 +402,7 @@ fn web_search_details() -> &'static str {
}
/// Keys accepted in `[features]` tables.
fn feature_for_key(key: &str) -> Option<Feature> {
pub(crate) fn feature_for_key(key: &str) -> Option<Feature> {
for spec in FEATURES {
if spec.key == key {
return Some(spec.id);
@@ -411,6 +411,14 @@ fn feature_for_key(key: &str) -> Option<Feature> {
legacy::feature_for_key(key)
}
pub(crate) fn canonical_feature_spec(key: &str) -> Option<&'static FeatureSpec> {
FEATURES.iter().find(|spec| spec.key == key)
}
pub(crate) fn canonical_feature_for_alias(key: &str) -> Option<Feature> {
legacy::canonical_feature_for_alias(key)
}
/// Returns `true` if the provided string matches a known feature toggle key.
pub fn is_known_feature_key(key: &str) -> bool {
feature_for_key(key).is_some()
+6 -5
View File
@@ -47,14 +47,15 @@ pub(crate) fn legacy_feature_keys() -> impl Iterator<Item = &'static str> {
ALIASES.iter().map(|alias| alias.legacy_key)
}
pub(crate) fn feature_for_key(key: &str) -> Option<Feature> {
pub(crate) fn canonical_feature_for_alias(key: &str) -> Option<Feature> {
ALIASES
.iter()
.find(|alias| alias.legacy_key == key)
.map(|alias| {
log_alias(alias.legacy_key, alias.feature);
alias.feature
})
.map(|alias| alias.feature)
}
pub(crate) fn feature_for_key(key: &str) -> Option<Feature> {
canonical_feature_for_alias(key).inspect(|feature| log_alias(key, *feature))
}
#[derive(Debug, Default)]