33package skillinject
44
55import (
6+ "bytes"
67 "encoding/json"
78 "fmt"
89 "os"
@@ -52,28 +53,68 @@ func classifyPluginAllowList(configPath, allowJsonPath, entriesJsonPath, pluginI
5253 return StateDrifted
5354}
5455
55- // mergePluginAllowList atomically rewrites the tool's plugin config
56- // JSON so that the allow-list at allowJsonPath contains pluginID and
57- // the entries map at entriesJsonPath has {pluginID: {"enabled": true}}.
58- // Preserves all other keys byte-for-byte modulo Go's JSON
59- // re-serialization (consistent 2-space indent, no key reordering
60- // beyond what encoding/json guarantees — alphabetical for map keys).
56+ // BackupSuffix is appended to the original config file path before
57+ // mergePluginAllowList overwrites it. Kept distinct from openclaw's
58+ // own .bak / .bak.N rotation so we can identify our own snapshots
59+ // and not interfere with the tool's rolling backup chain.
60+ const BackupSuffix = ".pilot-bak"
61+
62+ // mergePluginAllowList rewrites the tool's plugin config JSON so the
63+ // allow-list at allowJsonPath contains pluginID and the entries map
64+ // at entriesJsonPath has {pluginID: {"enabled": true}}. Preserves all
65+ // other keys (modulo Go's JSON re-marshal: 2-space indent + map keys
66+ // alphabetically sorted, which is stable across runs so subsequent
67+ // idempotent ticks produce byte-identical files).
68+ //
69+ // Safety contract — defense in depth, because openclaw.json is the
70+ // user's live config and corrupting it would brick their tool:
6171//
62- // If the config file is missing, it is created with only the managed
63- // keys. The daemon does NOT create the parent directory tree beyond
64- // the file itself; the tool's own install is expected to provide it.
72+ // (1) The original bytes are read into memory and held throughout
73+ // the operation as `originalBytes`. Every failure path below
74+ // has a way back to those bytes.
75+ // (2) If parsing the user's config fails, we refuse to write —
76+ // we never overwrite a malformed file the user might be
77+ // mid-editing. Caller surfaces the error in the next tick.
78+ // (3) If the post-merge marshal produces a different shape than
79+ // we expected (lost a key, scrambled a value), we refuse to
80+ // write and return an error. This is the "verification before
81+ // swap" rung — a paranoid round-trip self-check.
82+ // (4) Before atomically swapping the file, we write a sidecar
83+ // backup at <path>.pilot-bak with the original bytes. If
84+ // anything explodes between this point and the rename, the
85+ // backup is on disk and a human can restore manually.
86+ // (5) The swap itself uses .tmp + rename, which is atomic on
87+ // every POSIX filesystem we care about — no torn writes.
88+ // (6) After the swap, we read the file back and re-parse it.
89+ // If it doesn't parse, or the round-trip lost a top-level key
90+ // that was in the original, we ROLL BACK from the .pilot-bak
91+ // snapshot. This catches kernel/FS-level corruption that
92+ // slipped past the in-memory checks.
6593//
66- // Failure semantics: any read/parse/write error returns the error
67- // without partial writes (uses .tmp + rename).
94+ // If the config file is missing, the merge creates it with only the
95+ // managed keys. No backup is written in that case (nothing to
96+ // preserve).
6897func mergePluginAllowList (configPath , allowJsonPath , entriesJsonPath , pluginID string ) error {
69- var obj map [string ]any
98+ // (1) Snapshot the original bytes IN MEMORY. From here on,
99+ // `originalBytes` is our point-of-no-return — every failure
100+ // path below restores or aborts cleanly.
101+ var (
102+ originalBytes []byte
103+ originalExisted bool
104+ obj map [string ]any
105+ )
70106 raw , err := os .ReadFile (configPath )
71107 switch {
72108 case err != nil && os .IsNotExist (err ):
73109 obj = map [string ]any {}
74110 case err != nil :
75111 return fmt .Errorf ("read plugin config: %w" , err )
76112 default :
113+ originalBytes = append ([]byte (nil ), raw ... ) // defensive copy
114+ originalExisted = true
115+ // (2) Refuse to operate on a malformed config. The user
116+ // might be mid-edit; respecting their state is more
117+ // important than landing our merge this tick.
77118 if uerr := json .Unmarshal (raw , & obj ); uerr != nil {
78119 return fmt .Errorf ("parse plugin config (refusing to overwrite a malformed user config): %w" , uerr )
79120 }
@@ -82,6 +123,14 @@ func mergePluginAllowList(configPath, allowJsonPath, entriesJsonPath, pluginID s
82123 }
83124 }
84125
126+ // Snapshot which top-level keys the user had BEFORE we mutated
127+ // the in-memory map. Used by the post-swap verification step
128+ // (6) to catch silent key loss across the round-trip.
129+ originalTopKeys := make (map [string ]struct {}, len (obj ))
130+ for k := range obj {
131+ originalTopKeys [k ] = struct {}{}
132+ }
133+
85134 if err := ensureAllowListEntry (obj , allowJsonPath , pluginID ); err != nil {
86135 return err
87136 }
@@ -95,20 +144,107 @@ func mergePluginAllowList(configPath, allowJsonPath, entriesJsonPath, pluginID s
95144 }
96145 next = append (next , '\n' )
97146
147+ // (3) Self-check: round-trip the bytes we're about to write
148+ // through Unmarshal and make sure they still parse and still
149+ // contain every top-level key the user originally had. This
150+ // catches any in-memory corruption between mutate and write.
151+ if err := verifyMarshalRoundTrip (next , originalTopKeys , pluginID , allowJsonPath , entriesJsonPath ); err != nil {
152+ return fmt .Errorf ("pre-write verification failed (refusing to write): %w" , err )
153+ }
154+
98155 if err := os .MkdirAll (filepath .Dir (configPath ), 0o755 ); err != nil {
99156 return fmt .Errorf ("ensure parent dir for plugin config: %w" , err )
100157 }
101- tmp := configPath + ".tmp"
102- if err := os .WriteFile (tmp , next , 0o644 ); err != nil {
103- return fmt .Errorf ("write tmp plugin config: %w" , err )
158+
159+ // (4) Drop a sidecar backup of the original bytes BEFORE we
160+ // swap. Skipped when no original existed (nothing to back up)
161+ // or when the proposed write is byte-identical to the original
162+ // (no risk of data loss anyway).
163+ if originalExisted && ! bytes .Equal (originalBytes , next ) {
164+ bakPath := configPath + BackupSuffix
165+ if err := writeFileAtomic (bakPath , originalBytes , 0o644 ); err != nil {
166+ return fmt .Errorf ("write pre-merge backup: %w" , err )
167+ }
168+ }
169+
170+ // (5) Atomic swap: write tmp, rename.
171+ if err := writeFileAtomic (configPath , next , 0o644 ); err != nil {
172+ return fmt .Errorf ("write plugin config: %w" , err )
104173 }
105- if err := os .Rename (tmp , configPath ); err != nil {
174+
175+ // (6) Post-swap verification. Read the file back, re-parse, and
176+ // confirm every original top-level key survived AND our managed
177+ // keys are present. If anything is off, restore from the
178+ // .pilot-bak snapshot and return an error so the next tick
179+ // retries cleanly.
180+ if err := verifyOnDiskResult (configPath , originalTopKeys , pluginID , allowJsonPath , entriesJsonPath ); err != nil {
181+ if originalExisted {
182+ if rbErr := writeFileAtomic (configPath , originalBytes , 0o644 ); rbErr != nil {
183+ return fmt .Errorf ("post-write verification failed (%v); ROLLBACK ALSO FAILED (%v); manual restore: cp %s%s %s" ,
184+ err , rbErr , configPath , BackupSuffix , configPath )
185+ }
186+ return fmt .Errorf ("post-write verification failed; rolled back from in-memory snapshot: %w" , err )
187+ }
188+ return fmt .Errorf ("post-write verification failed (no rollback — no original existed): %w" , err )
189+ }
190+
191+ return nil
192+ }
193+
194+ // writeFileAtomic writes content to path via .tmp + rename. Used by
195+ // both the live-config swap and the .pilot-bak snapshot so both share
196+ // the same atomicity guarantee.
197+ func writeFileAtomic (path string , content []byte , mode os.FileMode ) error {
198+ tmp := path + ".tmp"
199+ if err := os .WriteFile (tmp , content , mode ); err != nil {
200+ return err
201+ }
202+ if err := os .Rename (tmp , path ); err != nil {
106203 _ = os .Remove (tmp )
107- return fmt . Errorf ( "rename plugin config into place: %w" , err )
204+ return err
108205 }
109206 return nil
110207}
111208
209+ // verifyMarshalRoundTrip parses bytes that are about to be written and
210+ // confirms (a) they parse cleanly, (b) every key that was in the
211+ // original top-level object is still present, (c) our id is in the
212+ // allow-list, (d) our entry has enabled=true. Pre-write rung in the
213+ // safety contract: catches in-memory corruption before it hits disk.
214+ func verifyMarshalRoundTrip (bytes []byte , originalTopKeys map [string ]struct {}, pluginID , allowJsonPath , entriesJsonPath string ) error {
215+ var rt map [string ]any
216+ if err := json .Unmarshal (bytes , & rt ); err != nil {
217+ return fmt .Errorf ("re-parse: %w" , err )
218+ }
219+ for k := range originalTopKeys {
220+ if _ , ok := rt [k ]; ! ok {
221+ return fmt .Errorf ("top-level key %q lost during marshal" , k )
222+ }
223+ }
224+ if ! allowListContains (rt , allowJsonPath , pluginID ) {
225+ return fmt .Errorf ("allow-list missing plugin id %q after marshal" , pluginID )
226+ }
227+ if ! entryEnabled (rt , entriesJsonPath , pluginID ) {
228+ return fmt .Errorf ("entries.%s.enabled missing or not true after marshal" , pluginID )
229+ }
230+ return nil
231+ }
232+
233+ // verifyOnDiskResult reads the file we just wrote and re-runs the
234+ // same checks as verifyMarshalRoundTrip. Post-swap rung in the safety
235+ // contract: catches filesystem-level corruption that slipped past the
236+ // in-memory check (truncated write, page-cache mismatch, etc.). Note:
237+ // this is paranoid; in practice it should never fire after a
238+ // successful writeFileAtomic. The cost is one fs.ReadFile per tick
239+ // per managed config.
240+ func verifyOnDiskResult (configPath string , originalTopKeys map [string ]struct {}, pluginID , allowJsonPath , entriesJsonPath string ) error {
241+ raw , err := os .ReadFile (configPath )
242+ if err != nil {
243+ return fmt .Errorf ("read-back: %w" , err )
244+ }
245+ return verifyMarshalRoundTrip (raw , originalTopKeys , pluginID , allowJsonPath , entriesJsonPath )
246+ }
247+
112248// walkObject traverses obj along a dotted path, materializing missing
113249// nested objects when create is true. Returns the final container and
114250// the leaf key. Returns nil + empty string if any intermediate node is
0 commit comments