11use crate :: error_code:: INTERNAL_ERROR_CODE ;
22use crate :: error_code:: INVALID_REQUEST_ERROR_CODE ;
3- use anyhow:: anyhow;
43use codex_app_server_protocol:: ConfigBatchWriteParams ;
54use codex_app_server_protocol:: ConfigLayer ;
65use codex_app_server_protocol:: ConfigLayerMetadata ;
@@ -16,7 +15,7 @@ use codex_app_server_protocol::OverriddenMetadata;
1615use codex_app_server_protocol:: WriteStatus ;
1716use codex_core:: config:: ConfigToml ;
1817use codex_core:: config:: edit:: ConfigEdit ;
19- use codex_core:: config:: edit:: apply_blocking ;
18+ use codex_core:: config:: edit:: ConfigEditsBuilder ;
2019use codex_core:: config_loader:: LoadedConfigLayers ;
2120use codex_core:: config_loader:: LoaderOverrides ;
2221use codex_core:: config_loader:: load_config_layers_with_overrides;
@@ -28,7 +27,6 @@ use sha2::Sha256;
2827use std:: collections:: HashMap ;
2928use std:: path:: Path ;
3029use std:: path:: PathBuf ;
31- use tokio:: task;
3230use toml:: Value as TomlValue ;
3331use toml_edit:: Item as TomlItem ;
3432
@@ -143,7 +141,6 @@ impl ConfigApi {
143141 }
144142
145143 let mut user_config = layers. user . config . clone ( ) ;
146- let mut mutated = false ;
147144 let mut parsed_segments = Vec :: new ( ) ;
148145 let mut config_edits = Vec :: new ( ) ;
149146
@@ -156,30 +153,33 @@ impl ConfigApi {
156153 config_write_error ( ConfigWriteErrorCode :: ConfigValidationError , message)
157154 } ) ?;
158155
159- let changed = apply_merge ( & mut user_config, & segments, parsed_value. as_ref ( ) , strategy)
160- . map_err ( |err| match err {
156+ apply_merge ( & mut user_config, & segments, parsed_value. as_ref ( ) , strategy) . map_err (
157+ |err| match err {
161158 MergeError :: PathNotFound => config_write_error (
162159 ConfigWriteErrorCode :: ConfigPathNotFound ,
163160 "Path not found" ,
164161 ) ,
165162 MergeError :: Validation ( message) => {
166163 config_write_error ( ConfigWriteErrorCode :: ConfigValidationError , message)
167164 }
168- } ) ?;
169-
170- if changed {
171- let updated_value = value_at_path ( & user_config, & segments) . cloned ( ) ;
172- let mut path = segments. clone ( ) ;
173- collect_config_edits (
174- & mut path,
175- original_value. as_ref ( ) ,
176- updated_value. as_ref ( ) ,
177- & mut config_edits,
178- )
179- . map_err ( |err| internal_error ( "failed to build config edits" , err) ) ?;
165+ } ,
166+ ) ?;
167+
168+ let updated_value = value_at_path ( & user_config, & segments) . cloned ( ) ;
169+ if original_value != updated_value {
170+ let edit = match updated_value {
171+ Some ( value) => ConfigEdit :: SetPath {
172+ segments : segments. clone ( ) ,
173+ value : toml_value_to_item ( & value)
174+ . map_err ( |err| internal_error ( "failed to build config edits" , err) ) ?,
175+ } ,
176+ None => ConfigEdit :: ClearPath {
177+ segments : segments. clone ( ) ,
178+ } ,
179+ } ;
180+ config_edits. push ( edit) ;
180181 }
181182
182- mutated |= changed;
183183 parsed_segments. push ( segments) ;
184184 }
185185
@@ -199,8 +199,10 @@ impl ConfigApi {
199199 )
200200 } ) ?;
201201
202- if mutated {
203- self . persist_user_config ( config_edits)
202+ if !config_edits. is_empty ( ) {
203+ ConfigEditsBuilder :: new ( & self . codex_home )
204+ . with_edits ( config_edits)
205+ . apply ( )
204206 . await
205207 . map_err ( |err| internal_error ( "failed to persist config.toml" , err) ) ?;
206208 }
@@ -269,22 +271,6 @@ impl ConfigApi {
269271 mdm,
270272 } )
271273 }
272-
273- async fn persist_user_config ( & self , edits : Vec < ConfigEdit > ) -> anyhow:: Result < ( ) > {
274- if edits. is_empty ( ) {
275- return Ok ( ( ) ) ;
276- }
277-
278- let codex_home = self . codex_home . clone ( ) ;
279-
280- task:: spawn_blocking ( move || -> anyhow:: Result < ( ) > {
281- apply_blocking ( & codex_home, None , & edits)
282- } )
283- . await
284- . map_err ( |err| anyhow ! ( "config persistence task panicked: {err}" ) ) ??;
285-
286- Ok ( ( ) )
287- }
288274}
289275
290276fn parse_value ( value : JsonValue ) -> Result < Option < TomlValue > , String > {
@@ -435,57 +421,6 @@ fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result<bool, MergeEr
435421 Ok ( parent. remove ( last) . is_some ( ) )
436422}
437423
438- fn collect_config_edits (
439- path : & mut Vec < String > ,
440- original : Option < & TomlValue > ,
441- updated : Option < & TomlValue > ,
442- edits : & mut Vec < ConfigEdit > ,
443- ) -> anyhow:: Result < ( ) > {
444- match ( original, updated) {
445- ( Some ( old_value) , Some ( new_value) ) if old_value == new_value => { }
446- ( Some ( TomlValue :: Table ( old_table) ) , Some ( TomlValue :: Table ( new_table) ) ) => {
447- for ( key, old_value) in old_table. iter ( ) {
448- if new_table. contains_key ( key) {
449- let mut nested = path. clone ( ) ;
450- nested. push ( key. clone ( ) ) ;
451- collect_config_edits ( & mut nested, Some ( old_value) , new_table. get ( key) , edits) ?;
452- } else {
453- let mut nested = path. clone ( ) ;
454- nested. push ( key. clone ( ) ) ;
455- edits. push ( ConfigEdit :: ClearPath { segments : nested } ) ;
456- }
457- }
458-
459- for ( key, new_value) in new_table. iter ( ) {
460- if old_table. contains_key ( key) {
461- continue ;
462- }
463-
464- let mut nested = path. clone ( ) ;
465- nested. push ( key. clone ( ) ) ;
466- edits. push ( ConfigEdit :: SetPath {
467- segments : nested,
468- value : toml_value_to_item ( new_value) ?,
469- } ) ;
470- }
471- }
472- ( _, Some ( new_value) ) => {
473- edits. push ( ConfigEdit :: SetPath {
474- segments : path. clone ( ) ,
475- value : toml_value_to_item ( new_value) ?,
476- } ) ;
477- }
478- ( Some ( _) , None ) => {
479- edits. push ( ConfigEdit :: ClearPath {
480- segments : path. clone ( ) ,
481- } ) ;
482- }
483- ( None , None ) => { }
484- }
485-
486- Ok ( ( ) )
487- }
488-
489424fn toml_value_to_item ( value : & TomlValue ) -> anyhow:: Result < TomlItem > {
490425 match value {
491426 TomlValue :: Table ( table) => {
@@ -506,7 +441,7 @@ fn toml_value_to_value(value: &TomlValue) -> anyhow::Result<toml_edit::Value> {
506441 TomlValue :: Integer ( val) => Ok ( toml_edit:: Value :: from ( * val) ) ,
507442 TomlValue :: Float ( val) => Ok ( toml_edit:: Value :: from ( * val) ) ,
508443 TomlValue :: Boolean ( val) => Ok ( toml_edit:: Value :: from ( * val) ) ,
509- TomlValue :: Datetime ( val) => Ok ( toml_edit:: Value :: from ( val. clone ( ) ) ) ,
444+ TomlValue :: Datetime ( val) => Ok ( toml_edit:: Value :: from ( * val) ) ,
510445 TomlValue :: Array ( items) => {
511446 let mut array = toml_edit:: Array :: new ( ) ;
512447 for item in items {
@@ -840,6 +775,99 @@ mod tests {
840775 use pretty_assertions:: assert_eq;
841776 use tempfile:: tempdir;
842777
778+ #[ test]
779+ fn toml_value_to_item_handles_nested_config_tables ( ) {
780+ let config = r#"
781+ [mcp_servers.docs]
782+ command = "docs-server"
783+
784+ [mcp_servers.docs.http_headers]
785+ X-Doc = "42"
786+ "# ;
787+
788+ let value: TomlValue = toml:: from_str ( config) . expect ( "parse config example" ) ;
789+ let item = toml_value_to_item ( & value) . expect ( "convert to toml_edit item" ) ;
790+
791+ let root = item. as_table ( ) . expect ( "root table" ) ;
792+ assert ! ( !root. is_implicit( ) , "root table should be explicit" ) ;
793+
794+ let mcp_servers = root
795+ . get ( "mcp_servers" )
796+ . and_then ( TomlItem :: as_table)
797+ . expect ( "mcp_servers table" ) ;
798+ assert ! (
799+ !mcp_servers. is_implicit( ) ,
800+ "mcp_servers table should be explicit"
801+ ) ;
802+
803+ let docs = mcp_servers
804+ . get ( "docs" )
805+ . and_then ( TomlItem :: as_table)
806+ . expect ( "docs table" ) ;
807+ assert_eq ! (
808+ docs. get( "command" )
809+ . and_then( TomlItem :: as_value)
810+ . and_then( toml_edit:: Value :: as_str) ,
811+ Some ( "docs-server" )
812+ ) ;
813+
814+ let http_headers = docs
815+ . get ( "http_headers" )
816+ . and_then ( TomlItem :: as_table)
817+ . expect ( "http_headers table" ) ;
818+ assert_eq ! (
819+ http_headers
820+ . get( "X-Doc" )
821+ . and_then( TomlItem :: as_value)
822+ . and_then( toml_edit:: Value :: as_str) ,
823+ Some ( "42" )
824+ ) ;
825+ }
826+
827+ #[ tokio:: test]
828+ async fn write_value_preserves_comments_and_order ( ) {
829+ let tmp = tempdir ( ) . expect ( "tempdir" ) ;
830+ let original = r#"# Codex user configuration
831+ model = "gpt-5"
832+ approval_policy = "on-request"
833+
834+ [notice]
835+ # Preserve this comment
836+ hide_full_access_warning = true
837+
838+ [features]
839+ unified_exec = true
840+ "# ;
841+ std:: fs:: write ( tmp. path ( ) . join ( CONFIG_FILE_NAME ) , original) . unwrap ( ) ;
842+
843+ let api = ConfigApi :: new ( tmp. path ( ) . to_path_buf ( ) , vec ! [ ] ) ;
844+ api. write_value ( ConfigValueWriteParams {
845+ file_path : Some ( tmp. path ( ) . join ( CONFIG_FILE_NAME ) . display ( ) . to_string ( ) ) ,
846+ key_path : "features.remote_compaction" . to_string ( ) ,
847+ value : json ! ( true ) ,
848+ merge_strategy : MergeStrategy :: Replace ,
849+ expected_version : None ,
850+ } )
851+ . await
852+ . expect ( "write succeeds" ) ;
853+
854+ let updated =
855+ std:: fs:: read_to_string ( tmp. path ( ) . join ( CONFIG_FILE_NAME ) ) . expect ( "read config" ) ;
856+ let expected = r#"# Codex user configuration
857+ model = "gpt-5"
858+ approval_policy = "on-request"
859+
860+ [notice]
861+ # Preserve this comment
862+ hide_full_access_warning = true
863+
864+ [features]
865+ unified_exec = true
866+ remote_compaction = true
867+ "# ;
868+ assert_eq ! ( updated, expected) ;
869+ }
870+
843871 #[ tokio:: test]
844872 async fn read_includes_origins_and_layers ( ) {
845873 let tmp = tempdir ( ) . expect ( "tempdir" ) ;
0 commit comments