From 523b40a129fdb8bac46b25b09276ccb1d7cb63fd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 25 Nov 2025 09:29:38 +0000 Subject: [PATCH] feat[app-serve]: config management (#7241) --- codex-rs/Cargo.lock | 1 + .../src/protocol/common.rs | 13 + .../app-server-protocol/src/protocol/v2.rs | 121 +++ codex-rs/app-server/Cargo.toml | 5 +- .../app-server/src/codex_message_processor.rs | 5 + codex-rs/app-server/src/config_api.rs | 974 ++++++++++++++++++ codex-rs/app-server/src/lib.rs | 15 +- codex-rs/app-server/src/message_processor.rs | 60 +- .../app-server/tests/common/mcp_process.rs | 27 + .../app-server/tests/suite/v2/config_rpc.rs | 347 +++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/config_loader/mod.rs | 20 +- 13 files changed, 1572 insertions(+), 19 deletions(-) create mode 100644 codex-rs/app-server/src/config_api.rs create mode 100644 codex-rs/app-server/tests/suite/v2/config_rpc.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 350561ba5..800ef1ce7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -870,6 +870,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "tempfile", "tokio", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index fecdc5b71..721ffe255 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -164,6 +164,19 @@ client_request_definitions! { response: v2::FeedbackUploadResponse, }, + ConfigRead => "config/read" { + params: v2::ConfigReadParams, + response: v2::ConfigReadResponse, + }, + ConfigValueWrite => "config/value/write" { + params: v2::ConfigValueWriteParams, + response: v2::ConfigWriteResponse, + }, + ConfigBatchWrite => "config/batchWrite" { + params: v2::ConfigBatchWriteParams, + response: v2::ConfigWriteResponse, + }, + GetAccount => "account/read" { params: v2::GetAccountParams, response: v2::GetAccountResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 13b7b8888..71249b667 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -128,6 +128,127 @@ v2_enum_from_core!( } ); +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigLayerName { + Mdm, + System, + SessionFlags, + User, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayerMetadata { + pub name: ConfigLayerName, + pub source: String, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayer { + pub name: ConfigLayerName, + pub source: String, + pub version: String, + pub config: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum MergeStrategy { + Replace, + Upsert, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WriteStatus { + Ok, + OkOverridden, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct OverriddenMetadata { + pub message: String, + pub overriding_layer: ConfigLayerMetadata, + pub effective_value: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWriteResponse { + pub status: WriteStatus, + pub version: String, + pub overridden_metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigWriteErrorCode { + ConfigLayerReadonly, + ConfigVersionConflict, + ConfigValidationError, + ConfigPathNotFound, + ConfigSchemaUnknownKey, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadParams { + #[serde(default)] + pub include_layers: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadResponse { + pub config: JsonValue, + pub origins: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub layers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigValueWriteParams { + pub file_path: String, + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigBatchWriteParams { + pub file_path: String, + pub edits: Vec, + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigEdit { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, +} + v2_enum_from_core!( pub enum CommandRiskLevel from codex_protocol::approvals::SandboxRiskLevel { Low, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 3189199a9..4ffe2d891 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -30,6 +30,9 @@ codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } +toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -51,7 +54,5 @@ mcp-types = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } -tempfile = { workspace = true } -toml = { workspace = true } wiremock = { workspace = true } shlex = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a9f56de11..9dc2f2e01 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -472,6 +472,11 @@ impl CodexMessageProcessor { ClientRequest::ExecOneOffCommand { request_id, params } => { self.exec_one_off_command(request_id, params).await; } + ClientRequest::ConfigRead { .. } + | ClientRequest::ConfigValueWrite { .. } + | ClientRequest::ConfigBatchWrite { .. } => { + warn!("Config request reached CodexMessageProcessor unexpectedly"); + } ClientRequest::GetAccountRateLimits { request_id, params: _, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs new file mode 100644 index 000000000..68bbdd8c6 --- /dev/null +++ b/codex-rs/app-server/src/config_api.rs @@ -0,0 +1,974 @@ +use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_REQUEST_ERROR_CODE; +use anyhow::anyhow; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigLayer; +use codex_app_server_protocol::ConfigLayerMetadata; +use codex_app_server_protocol::ConfigLayerName; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteErrorCode; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::OverriddenMetadata; +use codex_app_server_protocol::WriteStatus; +use codex_core::config::ConfigToml; +use codex_core::config_loader::LoadedConfigLayers; +use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::load_config_layers_with_overrides; +use codex_core::config_loader::merge_toml_values; +use serde_json::Value as JsonValue; +use serde_json::json; +use sha2::Digest; +use sha2::Sha256; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use tempfile::NamedTempFile; +use tokio::task; +use toml::Value as TomlValue; + +const SESSION_FLAGS_SOURCE: &str = "--config"; +const MDM_SOURCE: &str = "com.openai.codex/config_toml_base64"; +const CONFIG_FILE_NAME: &str = "config.toml"; + +#[derive(Clone)] +pub(crate) struct ConfigApi { + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, +} + +impl ConfigApi { + pub(crate) fn new(codex_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>) -> Self { + Self { + codex_home, + cli_overrides, + loader_overrides: LoaderOverrides::default(), + } + } + + #[cfg(test)] + fn with_overrides( + codex_home: PathBuf, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, + ) -> Self { + Self { + codex_home, + cli_overrides, + loader_overrides, + } + } + + pub(crate) async fn read( + &self, + params: ConfigReadParams, + ) -> Result { + let layers = self + .load_layers_state() + .await + .map_err(|err| internal_error("failed to read configuration layers", err))?; + + let effective = layers.effective_config(); + validate_config(&effective).map_err(|err| internal_error("invalid configuration", err))?; + + let response = ConfigReadResponse { + config: to_json_value(&effective), + origins: layers.origins(), + layers: params.include_layers.then(|| layers.layers_high_to_low()), + }; + + Ok(response) + } + + pub(crate) async fn write_value( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let edits = vec![(params.key_path, params.value, params.merge_strategy)]; + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + pub(crate) async fn batch_write( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let edits = params + .edits + .into_iter() + .map(|edit| (edit.key_path, edit.value, edit.merge_strategy)) + .collect(); + + self.apply_edits(params.file_path, params.expected_version, edits) + .await + } + + async fn apply_edits( + &self, + file_path: String, + expected_version: Option, + edits: Vec<(String, JsonValue, MergeStrategy)>, + ) -> Result { + let allowed_path = self.codex_home.join(CONFIG_FILE_NAME); + if !paths_match(&allowed_path, &file_path) { + return Err(config_write_error( + ConfigWriteErrorCode::ConfigLayerReadonly, + "Only writes to the user config are allowed", + )); + } + + let layers = self + .load_layers_state() + .await + .map_err(|err| internal_error("failed to load configuration", err))?; + + if let Some(expected) = expected_version.as_deref() + && expected != layers.user.version + { + return Err(config_write_error( + ConfigWriteErrorCode::ConfigVersionConflict, + "Configuration was modified since last read. Fetch latest version and retry.", + )); + } + + let mut user_config = layers.user.config.clone(); + let mut mutated = false; + let mut parsed_segments = Vec::new(); + + for (key_path, value, strategy) in edits.into_iter() { + let segments = parse_key_path(&key_path).map_err(|message| { + config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + let parsed_value = parse_value(value).map_err(|message| { + config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) + })?; + + let changed = apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy) + .map_err(|err| match err { + MergeError::PathNotFound => config_write_error( + ConfigWriteErrorCode::ConfigPathNotFound, + "Path not found", + ), + MergeError::Validation(message) => { + config_write_error(ConfigWriteErrorCode::ConfigValidationError, message) + } + })?; + + mutated |= changed; + parsed_segments.push(segments); + } + + validate_config(&user_config).map_err(|err| { + config_write_error( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + + let updated_layers = layers.with_user_config(user_config.clone()); + let effective = updated_layers.effective_config(); + validate_config(&effective).map_err(|err| { + config_write_error( + ConfigWriteErrorCode::ConfigValidationError, + format!("Invalid configuration: {err}"), + ) + })?; + + if mutated { + self.persist_user_config(&user_config) + .await + .map_err(|err| internal_error("failed to persist config.toml", err))?; + } + + let overridden = first_overridden_edit(&updated_layers, &effective, &parsed_segments); + let status = overridden + .as_ref() + .map(|_| WriteStatus::OkOverridden) + .unwrap_or(WriteStatus::Ok); + + Ok(ConfigWriteResponse { + status, + version: updated_layers.user.version.clone(), + overridden_metadata: overridden, + }) + } + + async fn load_layers_state(&self) -> std::io::Result { + let LoadedConfigLayers { + base, + managed_config, + managed_preferences, + } = load_config_layers_with_overrides(&self.codex_home, self.loader_overrides.clone()) + .await?; + + let user = LayerState::new( + ConfigLayerName::User, + self.codex_home.join(CONFIG_FILE_NAME), + base, + ); + + let session_flags = LayerState::new( + ConfigLayerName::SessionFlags, + PathBuf::from(SESSION_FLAGS_SOURCE), + { + let mut root = TomlValue::Table(toml::map::Map::new()); + for (path, value) in self.cli_overrides.iter() { + apply_override(&mut root, path, value.clone()); + } + root + }, + ); + + let system = managed_config.map(|cfg| { + LayerState::new( + ConfigLayerName::System, + system_config_path(&self.codex_home), + cfg, + ) + }); + + let mdm = managed_preferences + .map(|cfg| LayerState::new(ConfigLayerName::Mdm, PathBuf::from(MDM_SOURCE), cfg)); + + Ok(LayersState { + user, + session_flags, + system, + mdm, + }) + } + + async fn persist_user_config(&self, user_config: &TomlValue) -> anyhow::Result<()> { + let codex_home = self.codex_home.clone(); + let serialized = toml::to_string_pretty(user_config)?; + + task::spawn_blocking(move || -> anyhow::Result<()> { + std::fs::create_dir_all(&codex_home)?; + + let target = codex_home.join(CONFIG_FILE_NAME); + let tmp = NamedTempFile::new_in(&codex_home)?; + std::fs::write(tmp.path(), serialized.as_bytes())?; + tmp.persist(&target)?; + Ok(()) + }) + .await + .map_err(|err| anyhow!("config persistence task panicked: {err}"))??; + + Ok(()) + } +} + +fn parse_value(value: JsonValue) -> Result, String> { + if value.is_null() { + return Ok(None); + } + + serde_json::from_value::(value) + .map(Some) + .map_err(|err| format!("invalid value: {err}")) +} + +fn parse_key_path(path: &str) -> Result, String> { + if path.trim().is_empty() { + return Err("keyPath must not be empty".to_string()); + } + Ok(path + .split('.') + .map(std::string::ToString::to_string) + .collect()) +} + +fn apply_override(target: &mut TomlValue, path: &str, value: TomlValue) { + use toml::value::Table; + + let segments: Vec<&str> = path.split('.').collect(); + let mut current = target; + + for (idx, segment) in segments.iter().enumerate() { + let is_last = idx == segments.len() - 1; + + if is_last { + match current { + TomlValue::Table(table) => { + table.insert(segment.to_string(), value); + } + _ => { + let mut table = Table::new(); + table.insert(segment.to_string(), value); + *current = TomlValue::Table(table); + } + } + return; + } + + match current { + TomlValue::Table(table) => { + current = table + .entry((*segment).to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + _ => { + *current = TomlValue::Table(Table::new()); + if let TomlValue::Table(tbl) = current { + current = tbl + .entry((*segment).to_string()) + .or_insert_with(|| TomlValue::Table(Table::new())); + } + } + } + } +} + +#[derive(Debug)] +enum MergeError { + PathNotFound, + Validation(String), +} + +fn apply_merge( + root: &mut TomlValue, + segments: &[String], + value: Option<&TomlValue>, + strategy: MergeStrategy, +) -> Result { + let Some(value) = value else { + return clear_path(root, segments); + }; + + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + + for segment in parents { + match current { + TomlValue::Table(table) => { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + _ => { + *current = TomlValue::Table(toml::map::Map::new()); + if let TomlValue::Table(table) = current { + current = table + .entry(segment.clone()) + .or_insert_with(|| TomlValue::Table(toml::map::Map::new())); + } + } + } + } + + let table = current.as_table_mut().ok_or_else(|| { + MergeError::Validation("cannot set value on non-table parent".to_string()) + })?; + + if matches!(strategy, MergeStrategy::Upsert) + && let Some(existing) = table.get_mut(last) + && matches!(existing, TomlValue::Table(_)) + && matches!(value, TomlValue::Table(_)) + { + merge_toml_values(existing, value); + return Ok(true); + } + + let changed = table + .get(last) + .map(|existing| Some(existing) != Some(value)) + .unwrap_or(true); + table.insert(last.clone(), value.clone()); + Ok(changed) +} + +fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result { + let Some((last, parents)) = segments.split_last() else { + return Err(MergeError::Validation( + "keyPath must not be empty".to_string(), + )); + }; + + let mut current = root; + for segment in parents { + match current { + TomlValue::Table(table) => { + current = table.get_mut(segment).ok_or(MergeError::PathNotFound)?; + } + _ => return Err(MergeError::PathNotFound), + } + } + + let Some(parent) = current.as_table_mut() else { + return Err(MergeError::PathNotFound); + }; + + Ok(parent.remove(last).is_some()) +} + +#[derive(Clone)] +struct LayerState { + name: ConfigLayerName, + source: PathBuf, + config: TomlValue, + version: String, +} + +impl LayerState { + fn new(name: ConfigLayerName, source: PathBuf, config: TomlValue) -> Self { + let version = version_for_toml(&config); + Self { + name, + source, + config, + version, + } + } + + fn metadata(&self) -> ConfigLayerMetadata { + ConfigLayerMetadata { + name: self.name.clone(), + source: self.source.display().to_string(), + version: self.version.clone(), + } + } + + fn as_layer(&self) -> ConfigLayer { + ConfigLayer { + name: self.name.clone(), + source: self.source.display().to_string(), + version: self.version.clone(), + config: to_json_value(&self.config), + } + } +} + +#[derive(Clone)] +struct LayersState { + user: LayerState, + session_flags: LayerState, + system: Option, + mdm: Option, +} + +impl LayersState { + fn with_user_config(self, user_config: TomlValue) -> Self { + Self { + user: LayerState::new(self.user.name, self.user.source, user_config), + session_flags: self.session_flags, + system: self.system, + mdm: self.mdm, + } + } + + fn effective_config(&self) -> TomlValue { + let mut merged = self.user.config.clone(); + merge_toml_values(&mut merged, &self.session_flags.config); + if let Some(system) = &self.system { + merge_toml_values(&mut merged, &system.config); + } + if let Some(mdm) = &self.mdm { + merge_toml_values(&mut merged, &mdm.config); + } + merged + } + + fn origins(&self) -> HashMap { + let mut origins = HashMap::new(); + let mut path = Vec::new(); + + record_origins( + &self.user.config, + &self.user.metadata(), + &mut path, + &mut origins, + ); + record_origins( + &self.session_flags.config, + &self.session_flags.metadata(), + &mut path, + &mut origins, + ); + if let Some(system) = &self.system { + record_origins(&system.config, &system.metadata(), &mut path, &mut origins); + } + if let Some(mdm) = &self.mdm { + record_origins(&mdm.config, &mdm.metadata(), &mut path, &mut origins); + } + + origins + } + + fn layers_high_to_low(&self) -> Vec { + let mut layers = Vec::new(); + if let Some(mdm) = &self.mdm { + layers.push(mdm.as_layer()); + } + if let Some(system) = &self.system { + layers.push(system.as_layer()); + } + layers.push(self.session_flags.as_layer()); + layers.push(self.user.as_layer()); + layers + } +} + +fn record_origins( + value: &TomlValue, + meta: &ConfigLayerMetadata, + path: &mut Vec, + origins: &mut HashMap, +) { + match value { + TomlValue::Table(table) => { + for (key, val) in table { + path.push(key.clone()); + record_origins(val, meta, path, origins); + path.pop(); + } + } + TomlValue::Array(items) => { + for (idx, item) in items.iter().enumerate() { + path.push(idx.to_string()); + record_origins(item, meta, path, origins); + path.pop(); + } + } + _ => { + if !path.is_empty() { + origins.insert(path.join("."), meta.clone()); + } + } + } +} + +fn to_json_value(value: &TomlValue) -> JsonValue { + serde_json::to_value(value).unwrap_or(JsonValue::Null) +} + +fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { + let _: ConfigToml = value.clone().try_into()?; + Ok(()) +} + +fn version_for_toml(value: &TomlValue) -> String { + let json = to_json_value(value); + let canonical = canonical_json(&json); + let serialized = serde_json::to_vec(&canonical).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(serialized); + let hash = hasher.finalize(); + let hex = hash + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{hex}") +} + +fn canonical_json(value: &JsonValue) -> JsonValue { + match value { + JsonValue::Object(map) => { + let mut sorted = serde_json::Map::new(); + let mut keys = map.keys().cloned().collect::>(); + keys.sort(); + for key in keys { + if let Some(val) = map.get(&key) { + sorted.insert(key, canonical_json(val)); + } + } + JsonValue::Object(sorted) + } + JsonValue::Array(items) => JsonValue::Array(items.iter().map(canonical_json).collect()), + other => other.clone(), + } +} + +fn paths_match(expected: &Path, provided: &str) -> bool { + let provided_path = PathBuf::from(provided); + if let (Ok(expanded_expected), Ok(expanded_provided)) = + (expected.canonicalize(), provided_path.canonicalize()) + { + return expanded_expected == expanded_provided; + } + + expected == provided_path +} + +fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { + let mut current = root; + for segment in segments { + match current { + TomlValue::Table(table) => { + current = table.get(segment)?; + } + TomlValue::Array(items) => { + let idx: usize = segment.parse().ok()?; + current = items.get(idx)?; + } + _ => return None, + } + } + Some(current) +} + +fn override_message(layer: &ConfigLayerName) -> String { + match layer { + ConfigLayerName::Mdm => "Overridden by managed policy (mdm)".to_string(), + ConfigLayerName::System => "Overridden by managed config (system)".to_string(), + ConfigLayerName::SessionFlags => "Overridden by session flags".to_string(), + ConfigLayerName::User => "Overridden by user config".to_string(), + } +} + +fn compute_override_metadata( + layers: &LayersState, + effective: &TomlValue, + segments: &[String], +) -> Option { + let user_value = value_at_path(&layers.user.config, segments); + let effective_value = value_at_path(effective, segments); + + if user_value.is_some() && user_value == effective_value { + return None; + } + + if user_value.is_none() && effective_value.is_none() { + return None; + } + + let effective_layer = find_effective_layer(layers, segments); + let overriding_layer = effective_layer.unwrap_or_else(|| layers.user.metadata()); + let message = override_message(&overriding_layer.name); + + Some(OverriddenMetadata { + message, + overriding_layer, + effective_value: effective_value + .map(to_json_value) + .unwrap_or(JsonValue::Null), + }) +} + +fn first_overridden_edit( + layers: &LayersState, + effective: &TomlValue, + edits: &[Vec], +) -> Option { + for segments in edits { + if let Some(meta) = compute_override_metadata(layers, effective, segments) { + return Some(meta); + } + } + None +} + +fn find_effective_layer(layers: &LayersState, segments: &[String]) -> Option { + let check = + |state: &LayerState| value_at_path(&state.config, segments).map(|_| state.metadata()); + + if let Some(mdm) = &layers.mdm + && let Some(meta) = check(mdm) + { + return Some(meta); + } + if let Some(system) = &layers.system + && let Some(meta) = check(system) + { + return Some(meta); + } + if let Some(meta) = check(&layers.session_flags) { + return Some(meta); + } + check(&layers.user) +} + +fn system_config_path(codex_home: &Path) -> PathBuf { + if let Ok(path) = std::env::var("CODEX_MANAGED_CONFIG_PATH") { + return PathBuf::from(path); + } + + #[cfg(unix)] + { + let _ = codex_home; + PathBuf::from("/etc/codex/managed_config.toml") + } + + #[cfg(not(unix))] + { + codex_home.join("managed_config.toml") + } +} + +fn internal_error(context: &str, err: E) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{context}: {err}"), + data: None, + } +} + +fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: message.into(), + data: Some(json!({ + "config_write_error_code": code, + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + #[tokio::test] + async fn read_includes_origins_and_layers() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let api = ConfigApi::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let response = api + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("response"); + + assert_eq!( + response.config.get("approval_policy"), + Some(&json!("never")) + ); + + assert_eq!( + response + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerName::System + ); + let layers = response.layers.expect("layers present"); + assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); + assert_eq!(layers.last().unwrap().name, ConfigLayerName::User); + } + + #[tokio::test] + async fn write_value_reports_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_FILE_NAME), + "approval_policy = \"on-request\"", + ) + .unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let api = ConfigApi::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let result = api + .write_value(ConfigValueWriteParams { + file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + key_path: "approval_policy".to_string(), + value: json!("never"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + let read_after = api + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("read"); + let config_object = read_after.config.as_object().expect("object"); + assert_eq!(config_object.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + read_after + .origins + .get("approval_policy") + .expect("origin") + .name, + ConfigLayerName::System + ); + assert_eq!(result.status, WriteStatus::Ok); + assert!(result.overridden_metadata.is_none()); + } + + #[tokio::test] + async fn version_conflict_rejected() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + let error = api + .write_value(ConfigValueWriteParams { + file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + key_path: "model".to_string(), + value: json!("gpt-5"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:bogus".to_string()), + }) + .await + .expect_err("should fail"); + + assert_eq!(error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error + .data + .as_ref() + .and_then(|d| d.get("config_write_error_code")) + .and_then(serde_json::Value::as_str), + Some("configVersionConflict") + ); + } + + #[tokio::test] + async fn invalid_user_value_rejected_even_if_overridden_by_managed() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let api = ConfigApi::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let error = api + .write_value(ConfigValueWriteParams { + file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + key_path: "approval_policy".to_string(), + value: json!("bogus"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("should fail validation"); + + assert_eq!(error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error + .data + .as_ref() + .and_then(|d| d.get("config_write_error_code")) + .and_then(serde_json::Value::as_str), + Some("configValidationError") + ); + + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + assert_eq!(contents.trim(), "model = \"user\""); + } + + #[tokio::test] + async fn read_reports_managed_overrides_user_and_session_flags() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "model = \"user\"").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "model = \"system\"").unwrap(); + + let cli_overrides = vec![( + "model".to_string(), + TomlValue::String("session".to_string()), + )]; + + let api = ConfigApi::with_overrides( + tmp.path().to_path_buf(), + cli_overrides, + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let response = api + .read(ConfigReadParams { + include_layers: true, + }) + .await + .expect("response"); + + assert_eq!(response.config.get("model"), Some(&json!("system"))); + assert_eq!( + response.origins.get("model").expect("origin").name, + ConfigLayerName::System + ); + let layers = response.layers.expect("layers"); + assert_eq!(layers.first().unwrap().name, ConfigLayerName::System); + assert_eq!(layers.get(1).unwrap().name, ConfigLayerName::SessionFlags); + assert_eq!(layers.get(2).unwrap().name, ConfigLayerName::User); + } + + #[tokio::test] + async fn write_value_reports_managed_override() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap(); + + let managed_path = tmp.path().join("managed_config.toml"); + std::fs::write(&managed_path, "approval_policy = \"never\"").unwrap(); + + let api = ConfigApi::with_overrides( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(managed_path), + #[cfg(target_os = "macos")] + managed_preferences_base64: None, + }, + ); + + let result = api + .write_value(ConfigValueWriteParams { + file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + key_path: "approval_policy".to_string(), + value: json!("on-request"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("result"); + + assert_eq!(result.status, WriteStatus::OkOverridden); + let overridden = result.overridden_metadata.expect("overridden metadata"); + assert_eq!(overridden.overriding_layer.name, ConfigLayerName::System); + assert_eq!(overridden.effective_value, json!("never")); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 9ad6f50b2..2aee5bc93 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -18,6 +18,7 @@ use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::io::{self}; use tokio::sync::mpsc; +use toml::Value as TomlValue; use tracing::Level; use tracing::debug; use tracing::error; @@ -30,6 +31,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod bespoke_event_handling; mod codex_message_processor; +mod config_api; mod error_code; mod fuzzy_file_search; mod message_processor; @@ -80,11 +82,12 @@ pub async fn run_main( format!("error parsing -c overrides: {e}"), ) })?; - let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) - .await - .map_err(|e| { - std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) - })?; + let config = + Config::load_with_cli_overrides(cli_kv_overrides.clone(), ConfigOverrides::default()) + .await + .map_err(|e| { + std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) + })?; let feedback = CodexFeedback::new(); @@ -121,10 +124,12 @@ pub async fn run_main( // Task: process incoming messages. let processor_handle = tokio::spawn({ let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone(); let mut processor = MessageProcessor::new( outgoing_message_sender, codex_linux_sandbox_exe, std::sync::Arc::new(config), + cli_overrides, feedback.clone(), ); async move { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 403263b89..90560e9b3 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,16 +1,22 @@ use std::path::PathBuf; +use std::sync::Arc; use crate::codex_message_processor::CodexMessageProcessor; +use crate::config_api::ConfigApi; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; @@ -18,11 +24,12 @@ use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; -use std::sync::Arc; +use toml::Value as TomlValue; pub(crate) struct MessageProcessor { outgoing: Arc, codex_message_processor: CodexMessageProcessor, + config_api: ConfigApi, initialized: bool, } @@ -33,6 +40,7 @@ impl MessageProcessor { outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, config: Arc, + cli_overrides: Vec<(String, TomlValue)>, feedback: CodexFeedback, ) -> Self { let outgoing = Arc::new(outgoing); @@ -50,13 +58,15 @@ impl MessageProcessor { conversation_manager, outgoing.clone(), codex_linux_sandbox_exe, - config, + Arc::clone(&config), feedback, ); + let config_api = ConfigApi::new(config.codex_home.clone(), cli_overrides); Self { outgoing, codex_message_processor, + config_api, initialized: false, } } @@ -134,9 +144,20 @@ impl MessageProcessor { } } - self.codex_message_processor - .process_request(codex_request) - .await; + match codex_request { + ClientRequest::ConfigRead { request_id, params } => { + self.handle_config_read(request_id, params).await; + } + ClientRequest::ConfigValueWrite { request_id, params } => { + self.handle_config_value_write(request_id, params).await; + } + ClientRequest::ConfigBatchWrite { request_id, params } => { + self.handle_config_batch_write(request_id, params).await; + } + other => { + self.codex_message_processor.process_request(other).await; + } + } } pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) { @@ -156,4 +177,33 @@ impl MessageProcessor { pub(crate) fn process_error(&mut self, err: JSONRPCError) { tracing::error!("<- error: {:?}", err); } + + async fn handle_config_read(&self, request_id: RequestId, params: ConfigReadParams) { + match self.config_api.read(params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn handle_config_value_write( + &self, + request_id: RequestId, + params: ConfigValueWriteParams, + ) { + match self.config_api.write_value(params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn handle_config_batch_write( + &self, + request_id: RequestId, + params: ConfigBatchWriteParams, + ) { + match self.config_api.batch_write(params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } } diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 920a6fa01..e2da40bf2 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -18,6 +18,9 @@ use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; @@ -401,6 +404,30 @@ impl McpProcess { self.send_request("logoutChatGpt", None).await } + pub async fn send_config_read_request( + &mut self, + params: ConfigReadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/read", params).await + } + + pub async fn send_config_value_write_request( + &mut self, + params: ConfigValueWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/value/write", params).await + } + + pub async fn send_config_batch_write_request( + &mut self, + params: ConfigBatchWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("config/batchWrite", params).await + } + /// Send an `account/logout` JSON-RPC request. pub async fn send_logout_account_request(&mut self) -> anyhow::Result { self.send_request("account/logout", None).await diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs new file mode 100644 index 000000000..343a13c3c --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -0,0 +1,347 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; +use codex_app_server_protocol::ConfigLayerName; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MergeStrategy; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WriteStatus; +use pretty_assertions::assert_eq; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { + Ok(std::fs::write( + codex_home.path().join("config.toml"), + contents, + )?) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_returns_effective_and_layers() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" +sandbox_mode = "workspace-write" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!(config.get("model"), Some(&json!("gpt-user"))); + assert_eq!( + origins.get("model").expect("origin").name, + ConfigLayerName::User + ); + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 2); + assert_eq!(layers[0].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[1].name, ConfigLayerName::User); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_system_layer_and_overrides() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-user" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = ["/user"] +network_access = true +"#, + )?; + + let managed_path = codex_home.path().join("managed_config.toml"); + std::fs::write( + &managed_path, + r#" +model = "gpt-system" +approval_policy = "never" + +[sandbox_workspace_write] +writable_roots = ["/system"] +"#, + )?; + + let managed_path_str = managed_path.display().to_string(); + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[("CODEX_MANAGED_CONFIG_PATH", Some(&managed_path_str))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, + origins, + layers, + } = to_response(resp)?; + + assert_eq!(config.get("model"), Some(&json!("gpt-system"))); + assert_eq!( + origins.get("model").expect("origin").name, + ConfigLayerName::System + ); + + assert_eq!(config.get("approval_policy"), Some(&json!("never"))); + assert_eq!( + origins.get("approval_policy").expect("origin").name, + ConfigLayerName::System + ); + + assert_eq!(config.get("sandbox_mode"), Some(&json!("workspace-write"))); + assert_eq!( + origins.get("sandbox_mode").expect("origin").name, + ConfigLayerName::User + ); + + assert_eq!( + config + .get("sandbox_workspace_write") + .and_then(|v| v.get("writable_roots")), + Some(&json!(["/system"])) + ); + assert_eq!( + origins + .get("sandbox_workspace_write.writable_roots.0") + .expect("origin") + .name, + ConfigLayerName::System + ); + + assert_eq!( + config + .get("sandbox_workspace_write") + .and_then(|v| v.get("network_access")), + Some(&json!(true)) + ); + assert_eq!( + origins + .get("sandbox_workspace_write.network_access") + .expect("origin") + .name, + ConfigLayerName::User + ); + + let layers = layers.expect("layers present"); + assert_eq!(layers.len(), 3); + assert_eq!(layers[0].name, ConfigLayerName::System); + assert_eq!(layers[1].name, ConfigLayerName::SessionFlags); + assert_eq!(layers[2].name, ConfigLayerName::User); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_replaces_value() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let expected_version = read.origins.get("model").map(|m| m.version.clone()); + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: codex_home.path().join("config.toml").display().to_string(), + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version, + }) + .await?; + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + + assert_eq!(write.status, WriteStatus::Ok); + assert!(write.overridden_metadata.is_none()); + + let verify_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + }) + .await?; + let verify_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(verify_id)), + ) + .await??; + let verify: ConfigReadResponse = to_response(verify_resp)?; + assert_eq!(verify.config.get("model"), Some(&json!("gpt-new"))); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_rejects_version_conflict() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +model = "gpt-old" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: codex_home.path().join("config.toml").display().to_string(), + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: Some("sha256:stale".to_string()), + }) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(write_id)), + ) + .await??; + let code = err + .error + .data + .as_ref() + .and_then(|d| d.get("config_write_error_code")) + .and_then(|v| v.as_str()); + assert_eq!(code, Some("configVersionConflict")); + + Ok(()) +} + +#[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 mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: codex_home.path().join("config.toml").display().to_string(), + edits: vec![ + ConfigEdit { + key_path: "sandbox_mode".to_string(), + value: json!("workspace-write"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "sandbox_workspace_write".to_string(), + value: json!({ + "writable_roots": ["/tmp"], + "network_access": false + }), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + assert_eq!( + read.config.get("sandbox_mode"), + Some(&json!("workspace-write")) + ); + assert_eq!( + read.config + .get("sandbox_workspace_write") + .and_then(|v| v.get("writable_roots")), + Some(&json!(["/tmp"])) + ); + assert_eq!( + read.config + .get("sandbox_workspace_write") + .and_then(|v| v.get("network_access")), + Some(&json!(false)) + ); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index a8594e7ca..16d2142b2 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,4 +1,5 @@ mod account; +mod config_rpc; mod model_list; mod rate_limits; mod review; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b15465653..44cc651a0 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -70,7 +70,7 @@ pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex"; /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB -pub(crate) const CONFIG_TOML_FILE: &str = "config.toml"; +pub const CONFIG_TOML_FILE: &str = "config.toml"; /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 6b55b015a..4cd541d3e 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -11,15 +11,15 @@ use toml::Value as TomlValue; #[cfg(unix)] const CODEX_MANAGED_CONFIG_SYSTEM_PATH: &str = "/etc/codex/managed_config.toml"; -#[derive(Debug)] -pub(crate) struct LoadedConfigLayers { +#[derive(Debug, Clone)] +pub struct LoadedConfigLayers { pub base: TomlValue, pub managed_config: Option, pub managed_preferences: Option, } -#[derive(Debug, Default)] -pub(crate) struct LoaderOverrides { +#[derive(Debug, Default, Clone)] +pub struct LoaderOverrides { pub managed_config_path: Option, #[cfg(target_os = "macos")] pub managed_preferences_base64: Option, @@ -47,11 +47,15 @@ pub async fn load_config_as_toml(codex_home: &Path) -> io::Result { load_config_as_toml_with_overrides(codex_home, LoaderOverrides::default()).await } +pub async fn load_config_layers(codex_home: &Path) -> io::Result { + load_config_layers_with_overrides(codex_home, LoaderOverrides::default()).await +} + fn default_empty_table() -> TomlValue { TomlValue::Table(Default::default()) } -pub(crate) async fn load_config_layers_with_overrides( +pub async fn load_config_layers_with_overrides( codex_home: &Path, overrides: LoaderOverrides, ) -> io::Result { @@ -130,7 +134,7 @@ async fn read_config_from_path( } /// Merge config `overlay` into `base`, giving `overlay` precedence. -pub(crate) fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { +pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { if let TomlValue::Table(overlay_table) = overlay && let TomlValue::Table(base_table) = base { @@ -147,6 +151,10 @@ pub(crate) fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) { } fn managed_config_default_path(codex_home: &Path) -> PathBuf { + if let Ok(path) = std::env::var("CODEX_MANAGED_CONFIG_PATH") { + return PathBuf::from(path); + } + #[cfg(unix)] { let _ = codex_home;