@@ -52,14 +52,16 @@ enum OpenClawConfigFile {
5252 }
5353 }
5454
55+ @discardableResult
5556 static func saveDict(
5657 _ dict: [ String : Any ] ,
5758 preserveExistingKeys: Bool = false ,
5859 allowGatewayAuthMutation: Bool = false )
60+ -> Bool
5961 {
6062 self . withFileLock {
6163 // Nix mode disables config writes in production, but tests rely on saving temp configs.
62- if ProcessInfo . processInfo. isNixMode, !ProcessInfo. processInfo. isRunningTests { return }
64+ if ProcessInfo . processInfo. isNixMode, !ProcessInfo. processInfo. isRunningTests { return false }
6365 let url = self . url ( )
6466 let previousData = try ? Data ( contentsOf: url)
6567 let previousRoot = previousData. flatMap { self . parseConfigData ( $0) }
@@ -81,12 +83,7 @@ enum OpenClawConfigFile {
8183
8284 do {
8385 let data = try JSONSerialization . data ( withJSONObject: output, options: [ . prettyPrinted, . sortedKeys] )
84- try FileManager ( ) . createDirectory (
85- at: url. deletingLastPathComponent ( ) ,
86- withIntermediateDirectories: true )
87- try data. write ( to: url, options: [ . atomic] )
8886 let nextBytes = data. count
89- let nextAttributes = try ? FileManager ( ) . attributesOfItem ( atPath: url. path)
9087 let gatewayModeAfter = self . gatewayMode ( output)
9188 var suspicious = self . configWriteSuspiciousReasons (
9289 existsBefore: previousData != nil ,
@@ -98,6 +95,44 @@ enum OpenClawConfigFile {
9895 if preservedGatewayAuth {
9996 suspicious. append ( " gateway-auth-preserved " )
10097 }
98+ let blocking = self . configWriteBlockingReasons ( suspicious)
99+ if !blocking. isEmpty {
100+ let rejectedPath = self . persistRejectedConfigWrite ( data: data, configURL: url)
101+ self . logger. warning ( " config write rejected ( \( blocking. joined ( separator: " , " ) ) ) at \( url. path) " )
102+ self . appendConfigWriteAudit ( [
103+ " result " : " rejected " ,
104+ " configPath " : url. path,
105+ " existsBefore " : previousData != nil ,
106+ " previousBytes " : previousBytes ?? NSNull ( ) ,
107+ " nextBytes " : nextBytes,
108+ " previousDev " : self . fileSystemNumber ( previousAttributes ? [ . systemNumber] ) ?? NSNull ( ) ,
109+ " nextDev " : NSNull ( ) ,
110+ " previousIno " : self . fileSystemNumber ( previousAttributes ? [ . systemFileNumber] ) ?? NSNull ( ) ,
111+ " nextIno " : NSNull ( ) ,
112+ " previousMode " : self . posixMode ( previousAttributes ? [ . posixPermissions] ) ?? NSNull ( ) ,
113+ " nextMode " : NSNull ( ) ,
114+ " previousNlink " : self . fileAttributeInt ( previousAttributes ? [ . referenceCount] ) ?? NSNull ( ) ,
115+ " nextNlink " : NSNull ( ) ,
116+ " previousUid " : self . fileAttributeInt ( previousAttributes ? [ . ownerAccountID] ) ?? NSNull ( ) ,
117+ " nextUid " : NSNull ( ) ,
118+ " previousGid " : self . fileAttributeInt ( previousAttributes ? [ . groupOwnerAccountID] ) ?? NSNull ( ) ,
119+ " nextGid " : NSNull ( ) ,
120+ " hasMetaBefore " : hadMetaBefore,
121+ " hasMetaAfter " : self . hasMeta ( output) ,
122+ " gatewayModeBefore " : gatewayModeBefore ?? NSNull ( ) ,
123+ " gatewayModeAfter " : gatewayModeAfter ?? NSNull ( ) ,
124+ " preservedGatewayAuth " : preservedGatewayAuth,
125+ " suspicious " : suspicious,
126+ " blocking " : blocking,
127+ " rejectedPath " : rejectedPath ?? NSNull ( ) ,
128+ ] )
129+ return false
130+ }
131+ try FileManager ( ) . createDirectory (
132+ at: url. deletingLastPathComponent ( ) ,
133+ withIntermediateDirectories: true )
134+ try data. write ( to: url, options: [ . atomic] )
135+ let nextAttributes = try ? FileManager ( ) . attributesOfItem ( atPath: url. path)
101136 if !suspicious. isEmpty {
102137 self . logger. warning ( " config write anomaly ( \( suspicious. joined ( separator: " , " ) ) ) at \( url. path) " )
103138 }
@@ -123,9 +158,11 @@ enum OpenClawConfigFile {
123158 " hasMetaAfter " : self . hasMeta ( output) ,
124159 " gatewayModeBefore " : gatewayModeBefore ?? NSNull ( ) ,
125160 " gatewayModeAfter " : gatewayModeAfter ?? NSNull ( ) ,
161+ " preservedGatewayAuth " : preservedGatewayAuth,
126162 " suspicious " : suspicious,
127163 ] )
128164 self . observeConfigRead ( data: data, root: output, configURL: url, valid: true )
165+ return true
129166 } catch {
130167 self . logger. error ( " config save failed: \( error. localizedDescription) " )
131168 self . appendConfigWriteAudit ( [
@@ -138,9 +175,11 @@ enum OpenClawConfigFile {
138175 " hasMetaAfter " : self . hasMeta ( output) ,
139176 " gatewayModeBefore " : gatewayModeBefore ?? NSNull ( ) ,
140177 " gatewayModeAfter " : self . gatewayMode ( output) ?? NSNull ( ) ,
178+ " preservedGatewayAuth " : preservedGatewayAuth,
141179 " suspicious " : preservedGatewayAuth ? [ " gateway-auth-preserved " ] : [ ] ,
142180 " error " : error. localizedDescription,
143181 ] )
182+ return false
144183 }
145184 }
146185 }
@@ -416,6 +455,12 @@ enum OpenClawConfigFile {
416455 return reasons
417456 }
418457
458+ private static func configWriteBlockingReasons( _ suspicious: [ String ] ) -> [ String ] {
459+ suspicious. filter { reason in
460+ reason. hasPrefix ( " size-drop: " ) || reason == " gateway-mode-removed "
461+ }
462+ }
463+
419464 private static func configAuditLogURL( ) -> URL {
420465 self . stateDirURL ( )
421466 . appendingPathComponent ( " logs " , isDirectory: true )
@@ -594,6 +639,26 @@ enum OpenClawConfigFile {
594639 }
595640 }
596641
642+ private static func persistRejectedConfigWrite( data: Data , configURL: URL ) -> String ? {
643+ let timestamp = ISO8601DateFormatter ( ) . string ( from: Date ( ) )
644+ let url = configURL. deletingLastPathComponent ( )
645+ . appendingPathComponent ( " \( configURL. lastPathComponent) .rejected. \( self . configTimestampToken ( timestamp) ) " )
646+ let fileManager = FileManager ( )
647+ let privatePermissions : NSNumber = 0o600
648+ if fileManager. fileExists ( atPath: url. path) {
649+ try ? fileManager. setAttributes ( [ . posixPermissions: privatePermissions] , ofItemAtPath: url. path)
650+ return url. path
651+ }
652+ guard fileManager. createFile (
653+ atPath: url. path,
654+ contents: data,
655+ attributes: [ . posixPermissions: privatePermissions] )
656+ else {
657+ return nil
658+ }
659+ return url. path
660+ }
661+
597662 private static func observeConfigRead( data: Data , root: [ String : Any ] ? , configURL: URL , valid: Bool ) {
598663 let observedAt = ISO8601DateFormatter ( ) . string ( from: Date ( ) )
599664 let current = self . configFingerprint ( data: data, root: root, configURL: configURL, observedAt: observedAt)
0 commit comments