Skip to content

Commit f772a1a

Browse files
committed
feat(skillinject): in-memory snapshot + .pilot-bak + verify+rollback for plugin allowlist merge
The plugin-allow-list merge touches the user's live tool config (today: ~/.openclaw/openclaw.json). Corrupting that file would brick openclaw. Add defense-in-depth so the merge can never leave the user worse off than they started: (1) IN-MEMORY SNAPSHOT held throughout the operation. Every failure path can fall back to the original bytes. (2) Pre-write self-check: marshal the mutated map, re-parse the bytes, confirm every original top-level key is still present AND our managed keys landed correctly. Catches in-memory corruption before bytes hit disk. (3) SIDECAR BACKUP at <path>.pilot-bak written via atomic .tmp + rename BEFORE the live file is swapped. Distinct from openclaw's own .bak / .bak.N rotation so we don't churn its backup chain. Skipped when there's no original (fresh install) or the merge is a no-op (already in desired state). (4) Atomic swap of the live file via .tmp + rename. (5) Post-write verification: read the file back, re-parse, re-check every property from (2). If anything is off, ROLL BACK from the in-memory snapshot. (6) If the rollback write itself fails, the error message includes the manual recovery command: cp <path>.pilot-bak <path>. Tests (8 new in zz_plugin_allowlist_test.go): - .pilot-bak is written before swap when an original existed - .pilot-bak is NOT written when there's no original (fresh install) - .pilot-bak is NOT written when the swap would be a no-op - verifyMarshalRoundTrip catches top-level key loss - verifyMarshalRoundTrip catches allow-list missing our id - verifyOnDiskResult errors on garbage bytes (read-back failure) - rollback path leaves the original keys intact end-to-end - no .tmp files left behind after a happy-path merge Total tests in internal/skillinject: 18 (up from 10), all green. Full repo: pkg/, internal/, cmd/, plugins/, tests/ pass except one known-flaky Nagle test in pkg/daemon that's unrelated.
1 parent b6e648e commit f772a1a

2 files changed

Lines changed: 356 additions & 17 deletions

File tree

internal/skillinject/plugin_allowlist.go

Lines changed: 153 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package skillinject
44

55
import (
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).
6897
func 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

Comments
 (0)