diff --git a/internal/config/types.go b/internal/config/types.go index 50b153179..5f36159bf 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "reflect" "strings" ) @@ -363,6 +364,9 @@ type FileConfig struct { LocalControl LocalControlConfig `json:"localControl,omitempty"` STT STTConfig `json:"stt,omitempty"` CrossSessionInbound string `json:"crossSessionInbound,omitempty"` + // Extra preserves top-level fields written by newer Zero versions or + // extensions so a read-modify-write through this version is non-destructive. + Extra map[string]json.RawMessage `json:"-"` } func (cfg FileConfig) MarshalJSON() ([]byte, error) { @@ -400,7 +404,32 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) { if !cfg.STT.Empty() { raw.STT = &cfg.STT } - return json.Marshal(raw) + known, err := json.Marshal(raw) + if err != nil || len(cfg.Extra) == 0 { + return known, err + } + var merged map[string]json.RawMessage + if err := json.Unmarshal(known, &merged); err != nil { + return nil, err + } + for key, value := range cfg.Extra { + if !fileConfigKnownJSONKey(key) { + merged[key] = value + } + } + return json.Marshal(merged) +} + +func fileConfigKnownJSONKey(key string) bool { + if strings.EqualFold(key, "mcpServers") || strings.EqualFold(key, "mcp_servers") { + return true + } + for _, field := range knownJSONFields(reflect.TypeOf(FileConfig{})) { + if strings.EqualFold(key, field.canonical) { + return true + } + } + return false } type ResolveOptions struct { @@ -516,6 +545,18 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &raw); err != nil { return err } + var extra map[string]json.RawMessage + if err := json.Unmarshal(data, &extra); err != nil { + return err + } + for key := range extra { + if fileConfigKnownJSONKey(key) { + delete(extra, key) + } + } + if len(extra) == 0 { + extra = nil + } cfg.ActiveProvider = raw.ActiveProvider cfg.Providers = raw.Providers // A negative maxTurns is unambiguously invalid; without this it would be @@ -537,6 +578,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error { cfg.LocalControl = raw.LocalControl cfg.STT = raw.STT cfg.CrossSessionInbound = raw.CrossSessionInbound + cfg.Extra = extra if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) { cfg.MCP.Servers = map[string]MCPServerConfig{} } diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 372ca989e..362943468 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -43,3 +43,58 @@ func TestToolsConfigPresentOnOverridesAndResolved(t *testing.T) { t.Fatalf("ResolvedConfig.Tools.DeferThreshold = %d, want 4", resolved.Tools.DeferThreshold) } } + +func TestFileConfigExtraCannotOverrideKnownFields(t *testing.T) { + cfg := FileConfig{ + MaxTurns: 3, + Extra: map[string]json.RawMessage{ + "maxTurns": json.RawMessage(`99`), + "MaxTurns": json.RawMessage(`100`), + "future": json.RawMessage(`{"enabled":true}`), + }, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + var persisted map[string]json.RawMessage + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatal(err) + } + if got := string(persisted["maxTurns"]); got != "3" { + t.Fatalf("maxTurns = %s, want 3", got) + } + if _, exists := persisted["MaxTurns"]; exists { + t.Fatalf("case-variant extra overrode a known field: %s", data) + } + if got := string(persisted["future"]); got != `{"enabled":true}` { + t.Fatalf("future = %s, want preserved object", got) + } +} + +func TestFileConfigUnicodeFoldedKnownFieldRoundTrip(t *testing.T) { + const data = `{"mcpſervers":{"docs":{"url":"https://example.com/mcp"}}}` + + var cfg FileConfig + if err := json.Unmarshal([]byte(data), &cfg); err != nil { + t.Fatal(err) + } + if _, exists := cfg.MCP.Servers["docs"]; !exists { + t.Fatalf("MCP.Servers = %#v, want docs server", cfg.MCP.Servers) + } + if cfg.Extra != nil { + t.Fatalf("Extra = %#v, want Unicode-folded known field excluded", cfg.Extra) + } + + encoded, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + var roundTripped FileConfig + if err := json.Unmarshal(encoded, &roundTripped); err != nil { + t.Fatal(err) + } + if _, exists := roundTripped.MCP.Servers["docs"]; !exists { + t.Fatalf("round-tripped MCP.Servers = %#v, want docs server", roundTripped.MCP.Servers) + } +} diff --git a/internal/config/writer_test.go b/internal/config/writer_test.go index c66fc26ba..4d457c4fd 100644 --- a/internal/config/writer_test.go +++ b/internal/config/writer_test.go @@ -369,6 +369,37 @@ func TestSetThemePersistsUserPreference(t *testing.T) { } } +func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) { + path := filepath.Join(t.TempDir(), "zero.json") + if err := os.WriteFile(path, []byte(`{ + "preferences": {"theme": "default"}, + "futureSetting": {"a": 1} +}`), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := SetTheme(path, "dracula"); err != nil { + t.Fatalf("SetTheme() error = %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var persisted map[string]json.RawMessage + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatal(err) + } + var futureSetting struct { + A int `json:"a"` + } + if err := json.Unmarshal(persisted["futureSetting"], &futureSetting); err != nil { + t.Fatalf("unknown field was not preserved: %v\nconfig: %s", err, data) + } + if futureSetting.A != 1 { + t.Fatalf("futureSetting = %s, want {\"a\":1}", persisted["futureSetting"]) + } +} + func TestRecapsPreferenceRoundTrips(t *testing.T) { // Default (unset) is ON. if !(PreferencesConfig{}).RecapsEnabled() {