mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
config: enforce enterprise feature requirements (#13388)
## Why Enterprises can already constrain approvals, sandboxing, and web search through `requirements.toml` and MDM, but feature flags were still only configurable as managed defaults. That meant an enterprise could suggest feature values, but it could not actually pin them. This change closes that gap and makes enterprise feature requirements behave like the other constrained settings. The effective feature set now stays consistent with enterprise requirements during config load, when config writes are validated, and when runtime code mutates feature flags later in the session. It also tightens the runtime API for managed features. `ManagedFeatures` now follows the same constraint-oriented shape as `Constrained<T>` instead of exposing panic-prone mutation helpers, and production code can no longer construct it through an unconstrained `From<Features>` path. The PR also hardens the `compact_resume_fork` integration coverage on Windows. After the feature-management changes, `compact_resume_after_second_compaction_preserves_history` was overflowing the libtest/Tokio thread stacks on Windows, so the test now uses an explicit larger-stack harness as a pragmatic mitigation. That may not be the ideal root-cause fix, and it merits a parallel investigation into whether part of the async future chain should be boxed to reduce stack pressure instead. ## What Changed Enterprises can now pin feature values in `requirements.toml` with the requirements-side `features` table: ```toml [features] personality = true unified_exec = false ``` Only canonical feature keys are allowed in the requirements `features` table; omitted keys remain unconstrained. - Added a requirements-side pinned feature map to `ConfigRequirementsToml`, threaded it through source-preserving requirements merge and normalization in `codex-config`, and made the TOML surface use `[features]` (while still accepting legacy `[feature_requirements]` for compatibility). - Exposed `featureRequirements` from `configRequirements/read`, regenerated the JSON/TypeScript schema artifacts, and updated the app-server README. - Wrapped the effective feature set in `ManagedFeatures`, backed by `ConstrainedWithSource<Features>`, and changed its API to mirror `Constrained<T>`: `can_set(...)`, `set(...) -> ConstraintResult<()>`, and result-returning `enable` / `disable` / `set_enabled` helpers. - Removed the legacy-usage and bulk-map passthroughs from `ManagedFeatures`; callers that need those behaviors now mutate a plain `Features` value and reapply it through `set(...)`, so the constrained wrapper remains the enforcement boundary. - Removed the production loophole for constructing unconstrained `ManagedFeatures`. Non-test code now creates it through the configured feature-loading path, and `impl From<Features> for ManagedFeatures` is restricted to `#[cfg(test)]`. - Rejected legacy feature aliases in enterprise feature requirements, and return a load error when a pinned combination cannot survive dependency normalization. - Validated config writes against enterprise feature requirements before persisting changes, including explicit conflicting writes and profile-specific feature states that normalize into invalid combinations. - Updated runtime and TUI feature-toggle paths to use the constrained setter API and to persist or apply the effective post-constraint value rather than the requested value. - Updated the `core_test_support` Bazel target to include the bundled core model-catalog fixtures in its runtime data, so helper code that resolves `core/models.json` through runfiles works in remote Bazel test environments. - Renamed the core config test coverage to emphasize that effective feature values are normalized at runtime, while conflicting persisted config writes are rejected. - Ran `compact_resume_after_second_compaction_preserves_history` inside an explicit 8 MiB test thread and Tokio runtime worker stack, following the existing larger-stack integration-test pattern, to keep the Windows `compact_resume_fork` test slice from aborting while a parallel investigation continues into whether some of the underlying async futures should be boxed. ## Verification - `cargo test -p codex-config` - `cargo test -p codex-core feature_requirements_ -- --nocapture` - `cargo test -p codex-core load_requirements_toml_produces_expected_constraints -- --nocapture` - `cargo test -p codex-core compact_resume_after_second_compaction_preserves_history -- --nocapture` - `cargo test -p codex-core compact_resume_fork -- --nocapture` - Re-ran the built `codex-core` `tests/all` binary with `RUST_MIN_STACK=262144` for `compact_resume_after_second_compaction_preserves_history` to confirm the explicit-stack harness fixes the deterministic low-stack repro. - `cargo test -p codex-core` - This still fails locally in unrelated integration areas that expect the `codex` / `test_stdio_server` binaries or hit existing `search_tool` wiremock mismatches. ## Docs `developers.openai.com/codex` should document the requirements-side `[features]` table for enterprise and MDM-managed configuration, including that it only accepts canonical feature keys and that conflicting config writes are rejected.
This commit is contained in:
committed by
GitHub
Unverified
parent
e6773f856c
commit
bfff0c729f
@@ -1381,7 +1381,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
|
||||
let (home, mut config) = test_config().await;
|
||||
config.features.enable(Feature::Sqlite);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
|
||||
+23
-15
@@ -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;
|
||||
@@ -373,18 +373,24 @@ impl Codex {
|
||||
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source
|
||||
&& depth >= config.agent_max_depth
|
||||
{
|
||||
config.features.disable(Feature::Collab);
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
|
||||
if config.features.enabled(Feature::JsRepl)
|
||||
&& let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await
|
||||
{
|
||||
let message = format!(
|
||||
"Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}"
|
||||
);
|
||||
let _ = config.features.disable(Feature::JsRepl);
|
||||
let _ = config.features.disable(Feature::JsReplToolsOnly);
|
||||
let message = if config.features.enabled(Feature::JsRepl) {
|
||||
format!(
|
||||
"`js_repl` remains enabled because enterprise requirements pin it on, but the configured Node runtime is unavailable or incompatible. {err}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}"
|
||||
)
|
||||
};
|
||||
warn!("{message}");
|
||||
config.features.disable(Feature::JsRepl);
|
||||
config.features.disable(Feature::JsReplToolsOnly);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
@@ -620,7 +626,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>>,
|
||||
@@ -674,7 +680,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>,
|
||||
@@ -3020,7 +3026,7 @@ impl Session {
|
||||
self.features.enabled(feature)
|
||||
}
|
||||
|
||||
pub(crate) fn features(&self) -> Features {
|
||||
pub(crate) fn features(&self) -> ManagedFeatures {
|
||||
self.features.clone()
|
||||
}
|
||||
|
||||
@@ -4719,9 +4725,8 @@ async fn spawn_review_thread(
|
||||
.await;
|
||||
// For reviews, disable web_search and view_image regardless of global settings.
|
||||
let mut review_features = sess.features.clone();
|
||||
review_features
|
||||
.disable(crate::features::Feature::WebSearchRequest)
|
||||
.disable(crate::features::Feature::WebSearchCached);
|
||||
let _ = review_features.disable(crate::features::Feature::WebSearchRequest);
|
||||
let _ = review_features.disable(crate::features::Feature::WebSearchCached);
|
||||
let review_web_search_mode = WebSearchMode::Disabled;
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &review_model_info,
|
||||
@@ -8260,7 +8265,10 @@ mod tests {
|
||||
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
config.features.enable(Feature::ShellZshFork);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ShellZshFork)
|
||||
.expect("test config should allow shell_zsh_fork");
|
||||
config.zsh_path = None;
|
||||
let config = Arc::new(config);
|
||||
|
||||
@@ -8813,7 +8821,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,344 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use codex_config::Constrained;
|
||||
use codex_config::ConstrainedWithSource;
|
||||
use codex_config::ConstraintError;
|
||||
use codex_config::ConstraintResult;
|
||||
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;
|
||||
|
||||
/// Wrapper around [`Features`] which enforces constraints defined in
|
||||
/// `FeatureRequirementsToml` and provides normalization to ensure constraints
|
||||
/// are satisfied. Constraints are enforced on construction and mutation of
|
||||
/// `ManagedFeatures`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ManagedFeatures {
|
||||
value: ConstrainedWithSource<Features>,
|
||||
pinned_features: BTreeMap<Feature, bool>,
|
||||
}
|
||||
|
||||
impl ManagedFeatures {
|
||||
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 normalized_features = normalize_candidate(configured_features, &pinned_features);
|
||||
validate_pinned_features(&normalized_features, &pinned_features, source.as_ref())?;
|
||||
Ok(Self {
|
||||
value: ConstrainedWithSource::new(Constrained::allow_any(normalized_features), source),
|
||||
pinned_features,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self) -> &Features {
|
||||
self.value.get()
|
||||
}
|
||||
|
||||
fn normalize_and_validate(&self, candidate: Features) -> ConstraintResult<Features> {
|
||||
let normalized = normalize_candidate(candidate, &self.pinned_features);
|
||||
self.value.can_set(&normalized)?;
|
||||
validate_pinned_features_constraint(
|
||||
&normalized,
|
||||
&self.pinned_features,
|
||||
self.value.source.as_ref(),
|
||||
)?;
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn can_set(&self, candidate: &Features) -> ConstraintResult<()> {
|
||||
self.normalize_and_validate(candidate.clone()).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn set(&mut self, candidate: Features) -> ConstraintResult<()> {
|
||||
let normalized = self.normalize_and_validate(candidate)?;
|
||||
self.value.value.set(normalized)
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, feature: Feature, enabled: bool) -> ConstraintResult<()> {
|
||||
let mut next = self.get().clone();
|
||||
next.set_enabled(feature, enabled);
|
||||
self.set(next)
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, feature: Feature) -> ConstraintResult<()> {
|
||||
self.set_enabled(feature, true)
|
||||
}
|
||||
|
||||
pub fn disable(&mut self, feature: Feature) -> ConstraintResult<()> {
|
||||
self.set_enabled(feature, false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only available for tests to ensure `ManagedFeatures` is constructed with
|
||||
/// any required constraints taken into account.
|
||||
#[cfg(test)]
|
||||
impl From<Features> for ManagedFeatures {
|
||||
fn from(features: Features) -> Self {
|
||||
Self {
|
||||
value: ConstrainedWithSource::new(Constrained::allow_any(features), None),
|
||||
pinned_features: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ManagedFeatures {
|
||||
type Target = Features;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.get()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_candidate(
|
||||
mut candidate: Features,
|
||||
pinned_features: &BTreeMap<Feature, bool>,
|
||||
) -> Features {
|
||||
for (feature, enabled) in pinned_features {
|
||||
candidate.set_enabled(*feature, *enabled);
|
||||
}
|
||||
candidate.normalize_dependencies();
|
||||
candidate
|
||||
}
|
||||
|
||||
fn validate_pinned_features_constraint(
|
||||
normalized_features: &Features,
|
||||
pinned_features: &BTreeMap<Feature, bool>,
|
||||
source: Option<&RequirementSource>,
|
||||
) -> ConstraintResult<()> {
|
||||
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(ConstraintError::InvalidValue {
|
||||
field_name: "features",
|
||||
candidate: format!(
|
||||
"{}={}",
|
||||
feature.key(),
|
||||
normalized_features.enabled(*feature)
|
||||
),
|
||||
allowed,
|
||||
requirement_source: source.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_pinned_features(
|
||||
normalized_features: &Features,
|
||||
pinned_features: &BTreeMap<Feature, bool>,
|
||||
source: Option<&RequirementSource>,
|
||||
) -> std::io::Result<()> {
|
||||
validate_pinned_features_constraint(normalized_features, pinned_features, source)
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
|
||||
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 `features` requirement `{key}` from {source}: use canonical feature key `{}`",
|
||||
feature.key()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("invalid `features` requirement `{key}` from {source}"),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(pinned_features)
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
+167
-22
@@ -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,
|
||||
@@ -1684,7 +1686,19 @@ impl Config {
|
||||
codex_home: PathBuf,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
) -> std::io::Result<Self> {
|
||||
let requirements = config_layer_stack.requirements().clone();
|
||||
// Ensure that every field of ConfigRequirements is applied to the final
|
||||
// Config.
|
||||
let ConfigRequirements {
|
||||
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,
|
||||
network: network_requirements,
|
||||
} = config_layer_stack.requirements().clone();
|
||||
|
||||
let user_instructions = Self::load_instructions(Some(&codex_home));
|
||||
let mut startup_warnings = Vec::new();
|
||||
|
||||
@@ -1739,7 +1753,8 @@ 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, feature_requirements)?;
|
||||
let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile);
|
||||
let resolved_cwd = {
|
||||
use std::env;
|
||||
@@ -1780,7 +1795,7 @@ impl Config {
|
||||
config_profile.sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
&resolved_cwd,
|
||||
Some(&requirements.sandbox_policy),
|
||||
Some(&constrained_sandbox_policy),
|
||||
);
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
|
||||
for path in additional_writable_roots {
|
||||
@@ -1805,13 +1820,13 @@ impl Config {
|
||||
}
|
||||
});
|
||||
if !approval_policy_was_explicit
|
||||
&& let Err(err) = requirements.approval_policy.can_set(&approval_policy)
|
||||
&& let Err(err) = constrained_approval_policy.can_set(&approval_policy)
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"default approval policy is disallowed by requirements; falling back to required default"
|
||||
);
|
||||
approval_policy = requirements.approval_policy.value();
|
||||
approval_policy = constrained_approval_policy.value();
|
||||
}
|
||||
let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features)
|
||||
.unwrap_or(WebSearchMode::Cached);
|
||||
@@ -2052,18 +2067,6 @@ impl Config {
|
||||
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
|
||||
.unwrap_or_else(|| codex_home.to_path_buf());
|
||||
|
||||
// Ensure that every field of ConfigRequirements is applied to the final
|
||||
// Config.
|
||||
let ConfigRequirements {
|
||||
approval_policy: mut constrained_approval_policy,
|
||||
sandbox_policy: mut constrained_sandbox_policy,
|
||||
web_search_mode: mut constrained_web_search_mode,
|
||||
mcp_servers,
|
||||
exec_policy: _,
|
||||
enforce_residency,
|
||||
network: network_requirements,
|
||||
} = requirements;
|
||||
|
||||
apply_requirement_constrained_value(
|
||||
"approval_policy",
|
||||
approval_policy,
|
||||
@@ -4967,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 },
|
||||
@@ -5097,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 },
|
||||
@@ -5225,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 },
|
||||
@@ -5339,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 },
|
||||
@@ -5394,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,
|
||||
@@ -5998,6 +6002,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,
|
||||
@@ -6116,6 +6121,146 @@ trust_level = "untrusted"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feature_requirements_normalize_effective_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?;
|
||||
|
||||
let mut requested = config.features.get().clone();
|
||||
requested
|
||||
.disable(Feature::Personality)
|
||||
.enable(Feature::ShellTool);
|
||||
assert!(config.features.can_set(&requested).is_ok());
|
||||
config
|
||||
.features
|
||||
.set(requested)
|
||||
.expect("managed feature mutations should normalize successfully");
|
||||
|
||||
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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
[features]
|
||||
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,
|
||||
|
||||
@@ -245,6 +245,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 {
|
||||
@@ -353,10 +361,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
|
||||
}
|
||||
@@ -364,6 +369,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>) {
|
||||
@@ -404,7 +416,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);
|
||||
@@ -413,6 +425,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()
|
||||
|
||||
@@ -566,7 +566,10 @@ mod tests {
|
||||
fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() {
|
||||
let mut config = crate::config::test_config();
|
||||
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
||||
config.features.enable(Feature::AppsMcpGateway);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow apps gateway");
|
||||
|
||||
assert_eq!(
|
||||
codex_apps_mcp_url(&config),
|
||||
@@ -582,7 +585,10 @@ mod tests {
|
||||
let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config);
|
||||
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
||||
|
||||
config.features.enable(Feature::Apps);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow apps");
|
||||
|
||||
servers = with_codex_apps_mcp(servers, true, None, &config);
|
||||
let server = servers
|
||||
@@ -595,7 +601,10 @@ mod tests {
|
||||
|
||||
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
||||
|
||||
config.features.enable(Feature::AppsMcpGateway);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::AppsMcpGateway)
|
||||
.expect("test config should allow apps gateway");
|
||||
servers = with_codex_apps_mcp(servers, true, None, &config);
|
||||
let server = servers
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
|
||||
@@ -266,7 +266,7 @@ mod agent {
|
||||
// Approval policy
|
||||
agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
|
||||
// Consolidation runs as an internal sub-agent and must not recursively delegate.
|
||||
agent_config.features.disable(Feature::Collab);
|
||||
let _ = agent_config.features.disable(Feature::Collab);
|
||||
|
||||
// Sandbox policy
|
||||
let mut writable_roots = Vec::new();
|
||||
|
||||
@@ -475,7 +475,9 @@ mod tests {
|
||||
async fn js_repl_instructions_are_appended_when_enabled() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::JsRepl);
|
||||
cfg.features
|
||||
.enable(Feature::JsRepl)
|
||||
.expect("test config should allow js_repl");
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
@@ -488,9 +490,13 @@ mod tests {
|
||||
async fn js_repl_tools_only_instructions_are_feature_gated() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features
|
||||
let mut features = cfg.features.get().clone();
|
||||
features
|
||||
.enable(Feature::JsRepl)
|
||||
.enable(Feature::JsReplToolsOnly);
|
||||
cfg.features
|
||||
.set(features)
|
||||
.expect("test config should allow js_repl tool restrictions");
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
@@ -503,9 +509,13 @@ mod tests {
|
||||
async fn js_repl_original_resolution_guidance_is_feature_gated() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features
|
||||
let mut features = cfg.features.get().clone();
|
||||
features
|
||||
.enable(Feature::JsRepl)
|
||||
.enable(Feature::ImageDetailOriginal);
|
||||
cfg.features
|
||||
.set(features)
|
||||
.expect("test config should allow js_repl image detail settings");
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
@@ -730,7 +740,9 @@ mod tests {
|
||||
async fn apps_feature_does_not_emit_user_instructions_by_itself() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::Apps);
|
||||
cfg.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow apps");
|
||||
|
||||
let res = get_user_instructions(&cfg, None).await;
|
||||
assert_eq!(res, None);
|
||||
@@ -742,7 +754,9 @@ mod tests {
|
||||
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
|
||||
|
||||
let mut cfg = make_config(&tmp, 4096, None).await;
|
||||
cfg.features.enable(Feature::Apps);
|
||||
cfg.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow apps");
|
||||
|
||||
let res = get_user_instructions(&cfg, None)
|
||||
.await
|
||||
|
||||
@@ -1207,7 +1207,10 @@ mod tests {
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.features.disable(Feature::Sqlite);
|
||||
config
|
||||
.features
|
||||
.disable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite to be disabled");
|
||||
|
||||
let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?;
|
||||
let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?;
|
||||
@@ -1253,7 +1256,10 @@ mod tests {
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.features.enable(Feature::Sqlite);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let uuid = Uuid::from_u128(9010);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
@@ -1319,7 +1325,10 @@ mod tests {
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.features.enable(Feature::Sqlite);
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let uuid = Uuid::from_u128(9011);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
|
||||
@@ -95,7 +95,7 @@ async fn start_review_conversation(
|
||||
{
|
||||
panic!("by construction Constrained<WebSearchMode> must always support Disabled: {err}");
|
||||
}
|
||||
sub_agent_config.features.disable(Feature::Collab);
|
||||
let _ = sub_agent_config.features.disable(Feature::Collab);
|
||||
|
||||
// Set explicit review rubric for the sub-agent
|
||||
sub_agent_config.base_instructions = Some(crate::REVIEW_PROMPT.to_string());
|
||||
|
||||
@@ -973,7 +973,7 @@ fn apply_spawn_agent_runtime_overrides(
|
||||
|
||||
fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
|
||||
if child_depth >= config.agent_max_depth {
|
||||
config.features.disable(Feature::Collab);
|
||||
let _ = config.features.disable(Feature::Collab);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1989,7 +1989,8 @@ mod tests {
|
||||
let (_session, mut turn) = make_session_and_context().await;
|
||||
Arc::make_mut(&mut turn.config)
|
||||
.features
|
||||
.enable(Feature::ImageDetailOriginal);
|
||||
.enable(Feature::ImageDetailOriginal)
|
||||
.expect("test config should allow feature update");
|
||||
turn.model_info.supports_image_detail_original = true;
|
||||
|
||||
let content_item =
|
||||
|
||||
Reference in New Issue
Block a user