diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go index 17056aac7..a9937194d 100644 --- a/internal/api/handlers/management/plugins_test.go +++ b/internal/api/handlers/management/plugins_test.go @@ -526,16 +526,21 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { t.Parallel() pluginsDir := writeManagementPluginFile(t, "sample") + configPath := filepath.Join(t.TempDir(), "config.yaml") + if errWrite := os.WriteFile(configPath, []byte("plugins:\n configs:\n sample:\n enabled: true\n mode: safe\n keep:\n enabled: true\n mode: retained\n"), 0o600); errWrite != nil { + t.Fatalf("failed to write test config: %v", errWrite) + } h := &Handler{ cfg: &config.Config{ Plugins: config.PluginsConfig{ Dir: pluginsDir, Configs: map[string]config.PluginInstanceConfig{ "sample": pluginConfigFromYAML(t, "enabled: true\nmode: safe\n"), + "keep": pluginConfigFromYAML(t, "enabled: true\nmode: retained\n"), }, }, }, - configFilePath: writeTestConfigFile(t), + configFilePath: configPath, } reloads := make(chan *config.Config, 1) releaseReload := make(chan struct{}) @@ -577,6 +582,20 @@ func TestDeletePluginRemovesDiscoveredFileAndConfig(t *testing.T) { if _, ok := h.cfg.Plugins.Configs["sample"]; ok { t.Fatal("plugin config still exists after delete") } + if _, ok := h.cfg.Plugins.Configs["keep"]; !ok { + t.Fatal("retained plugin config was removed") + } + data, errReadConfig := os.ReadFile(configPath) + if errReadConfig != nil { + t.Fatalf("failed to read saved config: %v", errReadConfig) + } + text := string(data) + if strings.Contains(text, "sample:") || strings.Contains(text, "mode: safe") { + t.Fatalf("saved config still contains removed plugin:\n%s", text) + } + if !strings.Contains(text, "keep:") || !strings.Contains(text, "mode: retained") { + t.Fatalf("saved config lost retained plugin:\n%s", text) + } if _, errStat := os.Stat(path); !os.IsNotExist(errStat) { t.Fatalf("plugin file stat error = %v, want not exist", errStat) } diff --git a/internal/config/config.go b/internal/config/config.go index 85240ddac..7fe3120f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1181,6 +1181,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-model-alias") + pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "plugins", "configs") // Merge generated into original in-place, preserving comments/order of existing nodes. mergeMappingPreserve(original.Content[0], generated.Content[0]) @@ -1762,8 +1763,41 @@ func removeMapKey(mapNode *yaml.Node, key string) { } } -func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) { - if key == "" || dstRoot == nil || srcRoot == nil { +func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, keyPath ...string) { + if len(keyPath) == 0 || dstRoot == nil || srcRoot == nil { + return + } + if len(keyPath) > 1 { + dstParent := dstRoot + srcParent := srcRoot + for _, key := range keyPath[:len(keyPath)-1] { + if key == "" || dstParent == nil || dstParent.Kind != yaml.MappingNode { + return + } + dstIdx := findMapKeyIndex(dstParent, key) + if dstIdx < 0 || dstIdx+1 >= len(dstParent.Content) { + return + } + dstParent = dstParent.Content[dstIdx+1] + + if srcParent != nil && srcParent.Kind == yaml.MappingNode { + srcIdx := findMapKeyIndex(srcParent, key) + if srcIdx >= 0 && srcIdx+1 < len(srcParent.Content) { + srcParent = srcParent.Content[srcIdx+1] + } else { + srcParent = nil + } + } + } + if srcParent == nil || srcParent.Kind != yaml.MappingNode { + removeMapKey(dstParent, keyPath[len(keyPath)-1]) + return + } + pruneMappingToGeneratedKeys(dstParent, srcParent, keyPath[len(keyPath)-1]) + return + } + key := keyPath[0] + if key == "" { return } if dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode {