config: support enterprise feature requirements

This commit is contained in:
Michael Bolin
2026-03-03 16:09:33 -08:00
parent 745c48b088
commit d585add97e
19 changed files with 872 additions and 28 deletions
+5 -5
View File
@@ -23,11 +23,11 @@ use crate::compact::InitialContextInjection;
use crate::compact::run_inline_auto_compact_task;
use crate::compact::should_use_remote_compact_task;
use crate::compact_remote::run_inline_remote_auto_compact_task;
use crate::config::ManagedFeatures;
use crate::connectors;
use crate::exec_policy::ExecPolicyManager;
use crate::features::FEATURES;
use crate::features::Feature;
use crate::features::Features;
use crate::features::maybe_push_unstable_features_warning;
#[cfg(test)]
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
@@ -605,7 +605,7 @@ pub(crate) struct Session {
state: Mutex<SessionState>,
/// The set of enabled features should be invariant for the lifetime of the
/// session.
features: Features,
features: ManagedFeatures,
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
@@ -659,7 +659,7 @@ pub(crate) struct TurnContext {
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) tools_config: ToolsConfig,
pub(crate) features: Features,
pub(crate) features: ManagedFeatures,
pub(crate) ghost_snapshot: GhostSnapshotConfig,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
@@ -2985,7 +2985,7 @@ impl Session {
self.features.enabled(feature)
}
pub(crate) fn features(&self) -> Features {
pub(crate) fn features(&self) -> ManagedFeatures {
self.features.clone()
}
@@ -8619,7 +8619,7 @@ mod tests {
#[tokio::test]
async fn record_model_warning_appends_user_message() {
let (mut session, turn_context) = make_session_and_context().await;
let features = Features::with_defaults();
let features = crate::features::Features::with_defaults().into();
session.features = features;
session
@@ -0,0 +1,338 @@
use std::collections::BTreeMap;
use codex_config::Constrained;
use codex_config::ConstrainedWithSource;
use codex_config::ConstraintError;
use codex_config::FeatureRequirementsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use crate::config::ConfigToml;
use crate::config::profile::ConfigProfile;
use crate::features::Feature;
use crate::features::FeatureOverrides;
use crate::features::Features;
use crate::features::canonical_feature_for_key;
use crate::features::feature_for_key;
#[derive(Debug, Clone, PartialEq)]
pub struct ManagedFeatures {
value: ConstrainedWithSource<Features>,
}
impl ManagedFeatures {
fn new(value: ConstrainedWithSource<Features>) -> Self {
Self { value }
}
pub(crate) fn from_configured(
configured_features: Features,
feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<Self> {
let (pinned_features, source) = match feature_requirements {
Some(Sourced {
value: feature_requirements,
source,
}) => (
parse_feature_requirements(feature_requirements, &source)?,
Some(source),
),
None => (BTreeMap::new(), None),
};
let pinned_features_for_normalizer = pinned_features.clone();
let constrained = Constrained::normalized(configured_features, move |mut candidate| {
for (feature, enabled) in &pinned_features_for_normalizer {
candidate.set_enabled(*feature, *enabled);
}
candidate.normalize_dependencies();
candidate
})
.map_err(std::io::Error::from)?;
let managed_features = Self::new(ConstrainedWithSource::new(constrained, source.clone()));
validate_pinned_features(&managed_features, &pinned_features, source.as_ref())?;
Ok(managed_features)
}
pub fn to_features(&self) -> Features {
self.value.get().clone()
}
fn update(&mut self, update: impl FnOnce(&mut Features)) {
let mut next = self.to_features();
update(&mut next);
if let Err(err) = self.value.set(next) {
panic!("managed feature constraints should remain satisfiable: {err}");
}
}
pub fn set_enabled(&mut self, feature: Feature, enabled: bool) -> &mut Self {
self.update(|features| {
features.set_enabled(feature, enabled);
});
self
}
pub fn enable(&mut self, feature: Feature) -> &mut Self {
self.set_enabled(feature, true)
}
pub fn disable(&mut self, feature: Feature) -> &mut Self {
self.set_enabled(feature, false)
}
pub fn apply_map(&mut self, entries: &BTreeMap<String, bool>) -> &mut Self {
self.update(|features| {
features.apply_map(entries);
});
self
}
pub fn record_legacy_usage_force(&mut self, alias: &str, feature: Feature) -> &mut Self {
self.update(|features| {
features.record_legacy_usage_force(alias, feature);
});
self
}
pub fn record_legacy_usage(&mut self, alias: &str, feature: Feature) -> &mut Self {
self.update(|features| {
features.record_legacy_usage(alias, feature);
});
self
}
}
impl From<Features> for ManagedFeatures {
fn from(features: Features) -> Self {
match Self::from_configured(features, None) {
Ok(features) => features,
Err(err) => panic!("unconstrained features should always be constructible: {err}"),
}
}
}
impl std::ops::Deref for ManagedFeatures {
type Target = Features;
fn deref(&self) -> &Self::Target {
self.value.get()
}
}
fn feature_requirements_display(feature_requirements: &BTreeMap<Feature, bool>) -> String {
let values = feature_requirements
.iter()
.map(|(feature, enabled)| format!("{}={enabled}", feature.key()))
.collect::<Vec<_>>();
format!("[{}]", values.join(", "))
}
fn parse_feature_requirements(
feature_requirements: FeatureRequirementsToml,
source: &RequirementSource,
) -> std::io::Result<BTreeMap<Feature, bool>> {
let mut pinned_features = BTreeMap::new();
for (key, enabled) in feature_requirements.entries {
if let Some(feature) = canonical_feature_for_key(&key) {
pinned_features.insert(feature, enabled);
continue;
}
if let Some(feature) = feature_for_key(&key) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"invalid `feature_requirements` entry `{key}` from {source}: use canonical feature key `{}`",
feature.key()
),
));
}
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid `feature_requirements` entry `{key}` from {source}"),
));
}
Ok(pinned_features)
}
fn validate_pinned_features(
normalized_features: &Features,
pinned_features: &BTreeMap<Feature, bool>,
source: Option<&RequirementSource>,
) -> std::io::Result<()> {
let Some(source) = source else {
return Ok(());
};
let allowed = feature_requirements_display(pinned_features);
for (feature, enabled) in pinned_features {
if normalized_features.enabled(*feature) != *enabled {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
ConstraintError::InvalidValue {
field_name: "features",
candidate: format!(
"{}={}",
feature.key(),
normalized_features.enabled(*feature)
),
allowed,
requirement_source: source.clone(),
},
));
}
}
Ok(())
}
fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature, bool)> {
let mut explicit_settings = Vec::new();
if let Some(features) = cfg.features.as_ref() {
for (key, enabled) in &features.entries {
if let Some(feature) = feature_for_key(key) {
explicit_settings.push((format!("features.{key}"), feature, *enabled));
}
}
}
if let Some(enabled) = cfg.experimental_use_unified_exec_tool {
explicit_settings.push((
"experimental_use_unified_exec_tool".to_string(),
Feature::UnifiedExec,
enabled,
));
}
if let Some(enabled) = cfg.experimental_use_freeform_apply_patch {
explicit_settings.push((
"experimental_use_freeform_apply_patch".to_string(),
Feature::ApplyPatchFreeform,
enabled,
));
}
if let Some(enabled) = cfg.tools.as_ref().and_then(|tools| tools.web_search) {
explicit_settings.push((
"tools.web_search".to_string(),
Feature::WebSearchRequest,
enabled,
));
}
for (profile_name, profile) in &cfg.profiles {
if let Some(features) = profile.features.as_ref() {
for (key, enabled) in &features.entries {
if let Some(feature) = feature_for_key(key) {
explicit_settings.push((
format!("profiles.{profile_name}.features.{key}"),
feature,
*enabled,
));
}
}
}
if let Some(enabled) = profile.include_apply_patch_tool {
explicit_settings.push((
format!("profiles.{profile_name}.include_apply_patch_tool"),
Feature::ApplyPatchFreeform,
enabled,
));
}
if let Some(enabled) = profile.experimental_use_unified_exec_tool {
explicit_settings.push((
format!("profiles.{profile_name}.experimental_use_unified_exec_tool"),
Feature::UnifiedExec,
enabled,
));
}
if let Some(enabled) = profile.experimental_use_freeform_apply_patch {
explicit_settings.push((
format!("profiles.{profile_name}.experimental_use_freeform_apply_patch"),
Feature::ApplyPatchFreeform,
enabled,
));
}
if let Some(enabled) = profile.tools_web_search {
explicit_settings.push((
format!("profiles.{profile_name}.tools_web_search"),
Feature::WebSearchRequest,
enabled,
));
}
}
explicit_settings
}
pub(crate) fn validate_explicit_feature_settings_in_config_toml(
cfg: &ConfigToml,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
let Some(Sourced {
value: feature_requirements,
source,
}) = feature_requirements
else {
return Ok(());
};
let pinned_features = parse_feature_requirements(feature_requirements.clone(), source)?;
if pinned_features.is_empty() {
return Ok(());
}
let allowed = feature_requirements_display(&pinned_features);
for (path, feature, enabled) in explicit_feature_settings_in_config(cfg) {
if pinned_features
.get(&feature)
.is_some_and(|required| *required != enabled)
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
ConstraintError::InvalidValue {
field_name: "features",
candidate: format!("{path}={enabled}"),
allowed,
requirement_source: source.clone(),
},
));
}
}
Ok(())
}
pub(crate) fn validate_feature_requirements_in_config_toml(
cfg: &ConfigToml,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
fn validate_profile(
cfg: &ConfigToml,
profile_name: Option<&str>,
profile: &ConfigProfile,
feature_requirements: Option<&Sourced<FeatureRequirementsToml>>,
) -> std::io::Result<()> {
let configured_features = Features::from_config(cfg, profile, FeatureOverrides::default());
ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())
.map(|_| ())
.map_err(|err| {
if let Some(profile_name) = profile_name {
std::io::Error::new(
err.kind(),
format!(
"invalid feature configuration for profile `{profile_name}`: {err}"
),
)
} else {
err
}
})
}
validate_profile(cfg, None, &ConfigProfile::default(), feature_requirements)?;
for (profile_name, profile) in &cfg.profiles {
validate_profile(cfg, Some(profile_name), profile, feature_requirements)?;
}
Ok(())
}
+150 -6
View File
@@ -91,6 +91,7 @@ use toml::Value as TomlValue;
use toml_edit::DocumentMut;
pub mod edit;
mod managed_features;
mod network_proxy_spec;
mod permissions;
pub mod profile;
@@ -102,6 +103,7 @@ pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use managed_features::ManagedFeatures;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
pub use permissions::NetworkToml;
@@ -475,7 +477,7 @@ pub struct Config {
pub ghost_snapshot: GhostSnapshotConfig,
/// Centralized feature flags; source of truth for feature gating.
pub features: Features,
pub features: ManagedFeatures,
/// When `true`, suppress warnings about unstable (under development) features.
pub suppress_unstable_features_warning: bool,
@@ -1737,7 +1739,11 @@ impl Config {
web_search_request: override_tools_web_search_request,
};
let features = Features::from_config(&cfg, &config_profile, feature_overrides);
let configured_features = Features::from_config(&cfg, &config_profile, feature_overrides);
let features = ManagedFeatures::from_configured(
configured_features,
requirements.feature_requirements.clone(),
)?;
let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile);
let resolved_cwd = {
use std::env;
@@ -2054,6 +2060,7 @@ impl Config {
approval_policy: mut constrained_approval_policy,
sandbox_policy: mut constrained_sandbox_policy,
web_search_mode: mut constrained_web_search_mode,
feature_requirements: _,
mcp_servers,
exec_policy: _,
enforce_residency,
@@ -4963,7 +4970,7 @@ model_verbosity = "high"
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("o3".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -5093,7 +5100,7 @@ model_verbosity = "high"
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt3".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -5221,7 +5228,7 @@ model_verbosity = "high"
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("zdr".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -5335,7 +5342,7 @@ model_verbosity = "high"
use_experimental_unified_exec_tool: !cfg!(windows),
background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS,
ghost_snapshot: GhostSnapshotConfig::default(),
features: Features::with_defaults(),
features: Features::with_defaults().into(),
suppress_unstable_features_warning: false,
active_profile: Some("gpt5".to_string()),
active_project: ProjectConfig { trust_level: None },
@@ -5390,6 +5397,7 @@ model_verbosity = "high"
allowed_web_search_modes: Some(vec![
crate::config_loader::WebSearchModeRequirement::Cached,
]),
feature_requirements: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -6021,6 +6029,7 @@ mcp_oauth_callback_url = "https://example.com/callback"
crate::config_loader::SandboxModeRequirement::ReadOnly,
]),
allowed_web_search_modes: None,
feature_requirements: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -6139,6 +6148,141 @@ trust_level = "untrusted"
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_override_default_feature_values() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn explicit_feature_config_is_normalized_by_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
personality = false
shell_tool = 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 {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
assert!(
!config
.startup_warnings
.iter()
.any(|warning| warning.contains("Configured value for `features`")),
"{:?}",
config.startup_warnings
);
Ok(())
}
#[tokio::test]
async fn feature_requirements_normalize_runtime_feature_mutations() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([
("personality".to_string(), true),
("shell_tool".to_string(), false),
]),
}),
..Default::default()
}))
}))
.build()
.await?;
config
.features
.disable(Feature::Personality)
.enable(Feature::ShellTool);
assert!(config.features.enabled(Feature::Personality));
assert!(!config.features.enabled(Feature::ShellTool));
Ok(())
}
#[tokio::test]
async fn feature_requirements_reject_legacy_aliases() {
let codex_home = TempDir::new().expect("tempdir");
let err = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(crate::config_loader::ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("collab".to_string(), true)]),
}),
..Default::default()
}))
}))
.build()
.await
.expect_err("legacy aliases should be rejected");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
err.to_string()
.contains("use canonical feature key `multi_agent`"),
"{err}"
);
}
#[test]
fn experimental_realtime_ws_base_url_loads_from_config_toml() -> std::io::Result<()> {
let cfg: ConfigToml = toml::from_str(
+135
View File
@@ -1,6 +1,9 @@
use super::ConfigToml;
use super::deserialize_config_toml_with_base;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::managed_features::validate_explicit_feature_settings_in_config_toml;
use crate::config::managed_features::validate_feature_requirements_in_config_toml;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
@@ -331,6 +334,35 @@ impl ConfigService {
format!("Invalid configuration: {err}"),
)
})?;
let user_config_toml =
deserialize_config_toml_with_base(user_config.clone(), &self.codex_home).map_err(
|err| {
ConfigServiceError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
},
)?;
validate_explicit_feature_settings_in_config_toml(
&user_config_toml,
layers.requirements().feature_requirements.as_ref(),
)
.map_err(|err| {
ConfigServiceError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
validate_feature_requirements_in_config_toml(
&user_config_toml,
layers.requirements().feature_requirements.as_ref(),
)
.map_err(|err| {
ConfigServiceError::write(
ConfigWriteErrorCode::ConfigValidationError,
format!("Invalid configuration: {err}"),
)
})?;
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
let effective = updated_layers.effective_config();
@@ -706,6 +738,7 @@ mod tests {
use codex_app_server_protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
@@ -1088,6 +1121,108 @@ personality = true
assert_eq!(contents.trim(), "model = \"user\"");
}
#[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 = ConfigService::new(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: None,
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
macos_managed_config_requirements_base64: None,
},
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
}),
..Default::default()
}))
}),
);
let error = 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!(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.personality=false`"),
"{error}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(),
""
);
}
#[tokio::test]
async fn write_value_rejects_profile_feature_requirement_conflict() {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap();
let service = ConfigService::new(
tmp.path().to_path_buf(),
vec![],
LoaderOverrides {
managed_config_path: None,
#[cfg(target_os = "macos")]
managed_preferences_base64: None,
macos_managed_config_requirements_base64: None,
},
CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
}),
..Default::default()
}))
}),
);
let error = service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "profiles.enterprise.features.personality".to_string(),
value: serde_json::json!(false),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect_err("conflicting profile feature write should fail");
assert_eq!(
error.write_error_code(),
Some(ConfigWriteErrorCode::ConfigValidationError)
);
assert!(
error.to_string().contains(
"invalid value for `features`: `profiles.enterprise.features.personality=false`"
),
"{error}"
);
assert_eq!(
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(),
""
);
}
#[tokio::test]
async fn read_reports_managed_overrides_user_and_session_flags() {
let tmp = tempdir().expect("tempdir");
+1
View File
@@ -34,6 +34,7 @@ pub use codex_config::ConfigLoadError;
pub use codex_config::ConfigRequirements;
pub use codex_config::ConfigRequirementsToml;
pub use codex_config::ConstrainedWithSource;
pub use codex_config::FeatureRequirementsToml;
pub use codex_config::LoaderOverrides;
pub use codex_config::McpServerIdentity;
pub use codex_config::McpServerRequirement;
+25
View File
@@ -23,6 +23,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use tempfile::tempdir;
@@ -494,6 +495,9 @@ async fn load_requirements_toml_produces_expected_constraints() -> anyhow::Resul
allowed_approval_policies = ["never", "on-request"]
allowed_web_search_modes = ["cached"]
enforce_residency = "us"
[feature_requirements]
personality = true
"#,
)
.await?;
@@ -515,6 +519,15 @@ enforce_residency = "us"
.cloned(),
Some(vec![crate::config_loader::WebSearchModeRequirement::Cached])
);
assert_eq!(
config_requirements_toml
.feature_requirements
.as_ref()
.map(|requirements| requirements.value.clone()),
Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
})
);
let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?;
assert_eq!(
config_requirements.approval_policy.value(),
@@ -552,6 +565,15 @@ enforce_residency = "us"
config_requirements.enforce_residency.value(),
Some(crate::config_loader::ResidencyRequirement::Us)
);
assert_eq!(
config_requirements
.feature_requirements
.as_ref()
.map(|requirements| requirements.value.clone()),
Some(crate::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("personality".to_string(), true)]),
})
);
Ok(())
}
@@ -581,6 +603,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -629,6 +652,7 @@ allowed_approval_policies = ["on-request"]
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
feature_requirements: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
@@ -666,6 +690,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,
feature_requirements: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
+24 -5
View File
@@ -241,6 +241,14 @@ impl Features {
self
}
pub fn set_enabled(&mut self, f: Feature, enabled: bool) -> &mut Self {
if enabled {
self.enable(f)
} else {
self.disable(f)
}
}
pub fn record_legacy_usage_force(&mut self, alias: &str, feature: Feature) {
let (summary, details) = legacy_usage_notice(alias, feature);
self.legacy_usages.insert(LegacyFeatureUsage {
@@ -349,10 +357,7 @@ impl Features {
}
overrides.apply(&mut features);
if features.enabled(Feature::JsReplToolsOnly) && !features.enabled(Feature::JsRepl) {
tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only");
features.disable(Feature::JsReplToolsOnly);
}
features.normalize_dependencies();
features
}
@@ -360,6 +365,13 @@ impl Features {
pub fn enabled_features(&self) -> Vec<Feature> {
self.enabled.iter().copied().collect()
}
pub(crate) fn normalize_dependencies(&mut self) {
if self.enabled(Feature::JsReplToolsOnly) && !self.enabled(Feature::JsRepl) {
tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only");
self.disable(Feature::JsReplToolsOnly);
}
}
}
fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>) {
@@ -400,7 +412,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);
@@ -409,6 +421,13 @@ fn feature_for_key(key: &str) -> Option<Feature> {
legacy::feature_for_key(key)
}
pub(crate) fn canonical_feature_for_key(key: &str) -> Option<Feature> {
FEATURES
.iter()
.find(|spec| spec.key == key)
.map(|spec| spec.id)
}
/// 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()
+61 -7
View File
@@ -11,12 +11,15 @@ use codex_core::ThreadManager;
use codex_core::built_in_model_providers;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
use tempfile::TempDir;
@@ -28,11 +31,13 @@ use crate::responses::output_value_to_text;
use crate::responses::start_mock_server;
use crate::streaming_sse::StreamingSseServer;
use crate::wait_for_event;
use crate::wait_for_event_match;
use wiremock::Match;
use wiremock::matchers::path_regex;
type ConfigMutator = dyn FnOnce(&mut Config) + Send;
type PreBuildHook = dyn FnOnce(&Path) + Send + 'static;
const TEST_MODEL_WITH_EXPERIMENTAL_TOOLS: &str = "test-gpt-5.1-codex";
/// A collection of different ways the model can output an apply_patch call
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -172,11 +177,21 @@ impl TestCodexBuilder {
resume_from: Option<PathBuf>,
) -> anyhow::Result<TestCodex> {
let auth = self.auth.clone();
let thread_manager = codex_core::test_support::thread_manager_with_models_provider_and_home(
auth.clone(),
config.model_provider.clone(),
config.codex_home.clone(),
);
let thread_manager = if let Some(model_catalog) = config.model_catalog.clone() {
ThreadManager::new(
config.codex_home.clone(),
codex_core::test_support::auth_manager_from_auth(auth.clone()),
SessionSource::Exec,
Some(model_catalog),
CollaborationModesConfig::default(),
)
} else {
codex_core::test_support::thread_manager_with_models_provider_and_home(
auth.clone(),
config.model_provider.clone(),
config.codex_home.clone(),
)
};
let thread_manager = Arc::new(thread_manager);
let new_conversation = match resume_from {
@@ -232,6 +247,7 @@ impl TestCodexBuilder {
for mutator in mutators {
mutator(&mut config);
}
ensure_test_model_catalog(&mut config);
if config.include_apply_patch_tool {
config.features.enable(Feature::ApplyPatchFreeform);
@@ -243,6 +259,38 @@ impl TestCodexBuilder {
}
}
fn ensure_test_model_catalog(config: &mut Config) {
if config.model.as_deref() != Some(TEST_MODEL_WITH_EXPERIMENTAL_TOOLS)
|| config.model_catalog.is_some()
{
return;
}
let bundled_models_path =
codex_utils_cargo_bin::find_resource!("../models.json").expect("bundled models.json");
let bundled_models_contents =
std::fs::read_to_string(bundled_models_path).expect("read bundled models.json");
let bundled_models: ModelsResponse =
serde_json::from_str(&bundled_models_contents).expect("bundled models.json");
let mut model = bundled_models
.models
.iter()
.find(|candidate| candidate.slug == "gpt-5.1-codex")
.cloned()
.unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex"));
model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string();
model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string();
model.experimental_supported_tools = vec![
"test_sync_tool".to_string(),
"read_file".to_string(),
"grep_files".to_string(),
"list_dir".to_string(),
];
config.model_catalog = Some(ModelsResponse {
models: vec![model],
});
}
pub struct TestCodex {
pub home: Arc<TempDir>,
pub cwd: Arc<TempDir>,
@@ -334,8 +382,14 @@ impl TestCodex {
})
.await?;
wait_for_event(&self.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
let turn_id = wait_for_event_match(&self.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
wait_for_event(&self.codex, |event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
})
.await;
Ok(())
+4 -3
View File
@@ -22,6 +22,7 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -181,13 +182,13 @@ async fn search_tool_flag_adds_tool() -> Result<()> {
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let mock = mount_sse_sequence(
let mock = mount_sse_once(
&server,
vec![sse(vec![
sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
])],
]),
)
.await;