Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions codex-rs/app-server-protocol/src/protocol/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ v2_enum_from_core!(
}
);

// TODO(mbolin): Support in-repo layer.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
Expand All @@ -216,17 +217,63 @@ pub enum ConfigLayerSource {
/// Managed preferences layer delivered by MDM (macOS only).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Mdm { domain: String, key: String },
Mdm {
domain: String,
key: String,
},

/// Managed config layer from a file (usually `managed_config.toml`).
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
System { file: AbsolutePathBuf },
/// Session-layer overrides supplied via `-c`/`--config`.
SessionFlags,
/// User config layer from a file (usually `config.toml`).
System {
file: AbsolutePathBuf,
},

/// User config layer from $CODEX_HOME/config.toml. This layer is special
/// in that it is expected to be:
/// - writable by the user
/// - generally outside the workspace directory
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
User { file: AbsolutePathBuf },
User {
file: AbsolutePathBuf,
},

/// Session-layer overrides supplied via `-c`/`--config`.
SessionFlags,

/// `managed_config.toml` was designed to be a config that was loaded
/// as the last layer on top of everything else. This scheme did not quite
/// work out as intended, but we keep this variant as a "best effort" while
/// we phase out `managed_config.toml` in favor of `requirements.toml`.
LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf,
},

LegacyManagedConfigTomlFromMdm,
}

impl ConfigLayerSource {
/// A settings from a layer with a higher precedence will override a setting
/// from a layer with a lower precedence.
pub fn precedence(&self) -> i16 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest adding a small comment here about what lower / higher precedence means in practice. I'd probably explain it multiple ways that mean the same thing such that readers can pick the most understandable one.

"Higher precedence configs are applied last."
"Higher precedence configs overwrite values from lower precendence configs". etc.

match self {
ConfigLayerSource::Mdm { .. } => 0,
Copy link
Contributor

@gt-oai gt-oai Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps start at 10, not 0, to allow for room below? you can go negative, ignore me

ConfigLayerSource::System { .. } => 10,
ConfigLayerSource::User { .. } => 20,
ConfigLayerSource::SessionFlags => 30,
ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40,
ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50,
}
}
}

/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from
/// layer `A` will be overridden by settings from layer `B`.
impl PartialOrd for ConfigLayerSource {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.precedence().cmp(&other.precedence()))
}
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
Expand Down Expand Up @@ -344,7 +391,7 @@ pub struct ConfigWriteResponse {
pub status: WriteStatus,
pub version: String,
/// Canonical path to the config file that was written.
pub file_path: String,
pub file_path: AbsolutePathBuf,
pub overridden_metadata: Option<OverriddenMetadata>,
}

Expand All @@ -357,6 +404,7 @@ pub enum ConfigWriteErrorCode {
ConfigValidationError,
ConfigPathNotFound,
ConfigSchemaUnknownKey,
UserLayerNotFound,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
Expand Down
55 changes: 21 additions & 34 deletions codex-rs/app-server/tests/suite/v2/config_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ sandbox_mode = "workspace-write"
}
);
let layers = layers.expect("layers present");
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerSource::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });

Ok(())
}
Expand Down Expand Up @@ -137,9 +136,8 @@ view_image = false
);

let layers = layers.expect("layers present");
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].name, ConfigLayerSource::SessionFlags);
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].name, ConfigLayerSource::User { file: user_file });

Ok(())
}
Expand Down Expand Up @@ -211,15 +209,15 @@ writable_roots = [{}]
assert_eq!(config.model.as_deref(), Some("gpt-system"));
assert_eq!(
origins.get("model").expect("origin").name,
ConfigLayerSource::System {
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);

assert_eq!(config.approval_policy, Some(AskForApproval::Never));
assert_eq!(
origins.get("approval_policy").expect("origin").name,
ConfigLayerSource::System {
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
Expand All @@ -242,7 +240,7 @@ writable_roots = [{}]
.get("sandbox_workspace_write.writable_roots.0")
.expect("origin")
.name,
ConfigLayerSource::System {
ConfigLayerSource::LegacyManagedConfigTomlFromFile {
file: managed_file.clone(),
}
);
Expand All @@ -259,28 +257,28 @@ writable_roots = [{}]
);

let layers = layers.expect("layers present");
assert_eq!(layers.len(), 3);
assert_eq!(layers.len(), 2);
assert_eq!(
layers[0].name,
ConfigLayerSource::System { file: managed_file }
ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
);
assert_eq!(layers[1].name, ConfigLayerSource::SessionFlags);
assert_eq!(layers[2].name, ConfigLayerSource::User { file: user_file });
assert_eq!(layers[1].name, ConfigLayerSource::User { file: user_file });

Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_value_write_replaces_value() -> Result<()> {
let codex_home = TempDir::new()?;
let temp_dir = TempDir::new()?;
let codex_home = temp_dir.path().canonicalize()?;
write_config(
&codex_home,
&temp_dir,
r#"
model = "gpt-old"
"#,
)?;

let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let read_id = mcp
Expand Down Expand Up @@ -311,13 +309,7 @@ model = "gpt-old"
)
.await??;
let write: ConfigWriteResponse = to_response(write_resp)?;
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;

assert_eq!(write.status, WriteStatus::Ok);
assert_eq!(write.file_path, expected_file_path);
Expand Down Expand Up @@ -380,16 +372,17 @@ model = "gpt-old"

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_batch_write_applies_multiple_edits() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(&codex_home, "")?;
let tmp_dir = TempDir::new()?;
let codex_home = tmp_dir.path().canonicalize()?;
write_config(&tmp_dir, "")?;

let mut mcp = McpProcess::new(codex_home.path()).await?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

let writable_root = test_tmp_path_buf();
let batch_id = mcp
.send_config_batch_write_request(ConfigBatchWriteParams {
file_path: Some(codex_home.path().join("config.toml").display().to_string()),
file_path: Some(codex_home.join("config.toml").display().to_string()),
edits: vec![
ConfigEdit {
key_path: "sandbox_mode".to_string(),
Expand All @@ -415,13 +408,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> {
.await??;
let batch_write: ConfigWriteResponse = to_response(batch_resp)?;
assert_eq!(batch_write.status, WriteStatus::Ok);
let expected_file_path = codex_home
.path()
.join("config.toml")
.canonicalize()
.unwrap()
.display()
.to_string();
let expected_file_path = AbsolutePathBuf::resolve_path_against_base("config.toml", codex_home)?;
assert_eq!(batch_write.file_path, expected_file_path);

let read_id = mcp
Expand Down
38 changes: 38 additions & 0 deletions codex-rs/core/src/config/constraint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ impl ConstraintError {
),
}
}

pub fn empty_field(field_name: impl Into<String>) -> Self {
Self {
message: format!("field `{}` cannot be empty", field_name.into()),
}
}
}

pub type ConstraintResult<T> = Result<T, ConstraintError>;
Expand Down Expand Up @@ -57,6 +63,32 @@ impl<T: Send + Sync> Constrained<T> {
}
}

pub fn allow_only(value: T) -> Self
where
T: PartialEq + Send + Sync + fmt::Debug + Clone + 'static,
{
#[expect(clippy::expect_used)]
Self::new(value.clone(), move |candidate| {
if *candidate == value {
Ok(())
} else {
Err(ConstraintError::invalid_value(
format!("{candidate:?}"),
format!("{value:?}"),
))
}
})
.expect("initial value should always be valid")
}

/// Allow any value of T, using T's Default as the initial value.
pub fn allow_any_from_default() -> Self
where
T: Default,
{
Self::allow_any(T::default())
}

pub fn allow_values(initial_value: T, allowed: Vec<T>) -> ConstraintResult<Self>
where
T: PartialEq + Send + Sync + fmt::Debug + 'static,
Expand Down Expand Up @@ -129,6 +161,12 @@ mod tests {
assert_eq!(constrained.value(), -10);
}

#[test]
fn constrained_allow_any_default_uses_default_value() {
let constrained = Constrained::<i32>::allow_any_from_default();
assert_eq!(constrained.value(), 0);
}

#[test]
fn constrained_new_rejects_invalid_initial_value() {
let result = Constrained::new(0, |value| {
Expand Down
Loading
Loading