package config import ( "bytes" "fmt" "os" "strconv" "strings" "gopkg.in/yaml.v3" ) // SaveConfigPreserveComments writes the config back to YAML while preserving existing comments // and key ordering by loading the original file into a yaml.Node tree and updating values in-place. // Existing v8 documents are saved in the latest layout. Successful migration // also synchronizes cfg's OAuth scope for runtime snapshots. func SaveConfigPreserveComments(configFile string, cfg *Config, migrateV8 ...bool) error { persistCfg := cfg migrating := len(migrateV8) > 0 && migrateV8[0] // Load original YAML as a node tree to preserve comments and ordering. data, err := os.ReadFile(configFile) if err != nil { return err } var original yaml.Node if err = yaml.Unmarshal(data, &original); err != nil { return err } if original.Kind != yaml.DocumentNode || len(original.Content) == 0 { return fmt.Errorf("invalid yaml document structure") } if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode { return fmt.Errorf("expected root mapping node") } layout := deepCopyNode(original.Content[0]) flat, err := flattenV8(layout) if err != nil { return err } original.Content[0] = flat layout = expandConfigAliases(layout) migrating = migrating || IsV8ConfigLayout(layout) // Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from. rendered, err := yaml.Marshal((*legacyConfig)(persistCfg)) if err != nil { return err } var generated yaml.Node if err = yaml.Unmarshal(rendered, &generated); err != nil { return err } if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil { return fmt.Errorf("invalid generated yaml structure") } if generated.Content[0].Kind != yaml.MappingNode { return fmt.Errorf("expected generated root mapping node") } // Keep obsolete roots until v8 migration can preserve them as comments. if !migrating { removeLegacyAuthBlock(original.Content[0]) removeRemovedIntegrationKeys(original.Content[0]) removeLegacyGenerativeLanguageKeys(original.Content[0]) } removeLegacyOpenAICompatAPIKeys(original.Content[0]) 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], "oauth-request-scoped-errors") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-settings") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "antigravity", "sensitive-words-replace") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "devin", "sensitive-words-replace") replacePluginConfigsSubtree(original.Content[0], generated.Content[0]) // Merge generated into original in-place, preserving comments/order of existing nodes. mergeMappingPreserve(original.Content[0], generated.Content[0]) if err = restoreV8Layout(original.Content[0], layout, data, generated.Content[0]); err != nil { return err } if !migrating { // Keep historical client fields at their original legacy paths. Otherwise // the generated client path makes the next v0 save look like a v8 file. for _, alias := range v8ClientPaths { if yamlPath(layout, alias.old) == nil || yamlPath(layout, alias.current) != nil || yamlPath(original.Content[0], alias.current) == nil { continue } copy := copyYAMLPathValue(original.Content[0], alias.current) deleteYAMLPath(original.Content[0], alias.current) setYAMLPathWithComments(original.Content[0], alias.old, copy) } } NormalizeCollectionNodeStyles(original.Content[0]) // Encode and validate the layout before opening the destination for writing. var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) if err = enc.Encode(&original); err != nil { _ = enc.Close() return err } if err = enc.Close(); err != nil { return err } data = NormalizeCommentIndentation(buf.Bytes()) var migrated *Config if migrating { data, _, err = NormalizeConfigLayout(data, true) if err != nil { return err } migrated = new(Config) if err = yaml.Unmarshal(data, migrated); err != nil { return fmt.Errorf("decode migrated config: %w", err) } } if err = os.WriteFile(configFile, data, 0600); err != nil { return err } if migrated != nil { // Keep runtime-only values intact and publish scope only after the write succeeds. cfg.OAuthOnlyFields = migrated.OAuthOnlyFields } return nil } // SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"] // while preserving comments and positions. func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error { data, err := os.ReadFile(configFile) if err != nil { return err } data, err = updateNestedScalarYAML(data, path, value) if err != nil { return err } return os.WriteFile(configFile, data, 0600) } // updateNestedScalarYAML returns data with the nested scalar key path like ["a","b"] set to // value, preserving comments and positions. func updateNestedScalarYAML(data []byte, path []string, value string) ([]byte, error) { var root yaml.Node if errUnmarshal := yaml.Unmarshal(data, &root); errUnmarshal != nil { return nil, errUnmarshal } if root.Kind != yaml.DocumentNode || len(root.Content) == 0 { return nil, fmt.Errorf("invalid yaml document structure") } // Resolve aliases and merge keys before updating a path. Otherwise replacing // an alias with a mapping drops inherited siblings or mutates a shared anchor. var decoded map[string]any if errDecode := root.Decode(&decoded); errDecode != nil { return nil, errDecode } root.Content[0] = expandConfigAliases(root.Content[0]) node := root.Content[0] // descend mapping nodes following path for i, key := range path { if i == len(path)-1 { // set final scalar v := getOrCreateMapValue(node, key) v.Kind = yaml.ScalarNode v.Tag = "!!str" v.Value = value } else { next := getOrCreateMapValue(node, key) if next.Kind != yaml.MappingNode { next.Kind = yaml.MappingNode next.Tag = "!!map" } node = next } } var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) if errEncode := enc.Encode(&root); errEncode != nil { _ = enc.Close() return nil, errEncode } if errClose := enc.Close(); errClose != nil { return nil, errClose } return NormalizeCommentIndentation(buf.Bytes()), nil } // NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned. func NormalizeCommentIndentation(data []byte) []byte { lines := bytes.Split(data, []byte("\n")) changed := false for i, line := range lines { trimmed := bytes.TrimLeft(line, " \t") if len(trimmed) == 0 || trimmed[0] != '#' { continue } if len(trimmed) == len(line) { continue } lines[i] = append([]byte(nil), trimmed...) changed = true } if !changed { return data } return bytes.Join(lines, []byte("\n")) } // getOrCreateMapValue finds the value node for a given key in a mapping node. // If not found, it appends a new key/value pair and returns the new value node. func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node { if mapNode.Kind != yaml.MappingNode { mapNode.Kind = yaml.MappingNode mapNode.Tag = "!!map" mapNode.Content = nil } for i := 0; i+1 < len(mapNode.Content); i += 2 { k := mapNode.Content[i] if k.Value == key { return mapNode.Content[i+1] } } // append new key/value mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}) val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""} mapNode.Content = append(mapNode.Content, val) return val } // mergeMappingPreserve merges keys from src into dst mapping node while preserving // key order and comments of existing keys in dst. New keys are only added if their // value is non-zero and not a known default to avoid polluting the config with defaults. func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) { var currentPath []string if len(path) > 0 { currentPath = path[0] } if dst == nil || src == nil { return } if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode { // If kinds do not match, prefer replacing dst with src semantics in-place // but keep dst node object to preserve any attached comments at the parent level. copyNodeShallow(dst, src) return } for i := 0; i+1 < len(src.Content); i += 2 { sk := src.Content[i] sv := src.Content[i+1] idx := findMapKeyIndex(dst, sk.Value) childPath := appendPath(currentPath, sk.Value) if isPluginConfigsPath(childPath) { continue } if idx >= 0 { // Merge into existing value node (always update, even to zero values) dv := dst.Content[idx+1] mergeNodePreserve(dv, sv, childPath) } else { // New key: only add if value is non-zero and not a known default candidate := deepCopyNode(sv) pruneKnownDefaultsInNewNode(childPath, candidate) if isKnownDefaultValue(childPath, candidate) { continue } dst.Content = append(dst.Content, deepCopyNode(sk), candidate) } } } // mergeNodePreserve merges src into dst for scalars, mappings and sequences while // reusing destination nodes to keep comments and anchors. For sequences, it updates // in-place by index. func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) { var currentPath []string if len(path) > 0 { currentPath = path[0] } if dst == nil || src == nil { return } switch src.Kind { case yaml.MappingNode: if dst.Kind != yaml.MappingNode { copyNodeShallow(dst, src) } mergeMappingPreserve(dst, src, currentPath) if shouldPruneNestedMappingKeys(currentPath) { pruneMissingMapKeys(dst, src) } case yaml.SequenceNode: // Preserve explicit null style if dst was null and src is empty sequence if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 { // Keep as null to preserve original style return } if dst.Kind != yaml.SequenceNode { dst.Kind = yaml.SequenceNode dst.Tag = "!!seq" dst.Content = nil } reorderSequenceForMerge(dst, src) // Update elements in place minContent := len(dst.Content) if len(src.Content) < minContent { minContent = len(src.Content) } for i := 0; i < minContent; i++ { if dst.Content[i] == nil { dst.Content[i] = deepCopyNode(src.Content[i]) continue } mergeNodePreserve(dst.Content[i], src.Content[i], currentPath) if dst.Content[i] != nil && src.Content[i] != nil && dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode { pruneMissingMapKeys(dst.Content[i], src.Content[i]) } } // Append any extra items from src for i := len(dst.Content); i < len(src.Content); i++ { dst.Content = append(dst.Content, deepCopyNode(src.Content[i])) } // Truncate if dst has extra items not in src if len(src.Content) < len(dst.Content) { dst.Content = dst.Content[:len(src.Content)] } case yaml.ScalarNode, yaml.AliasNode: // For scalars, update Tag and Value but keep Style from dst to preserve quoting dst.Kind = src.Kind dst.Tag = src.Tag dst.Value = src.Value // Keep dst.Style as-is intentionally case 0: // Unknown/empty kind; do nothing default: // Fallback: replace shallowly copyNodeShallow(dst, src) } } // findMapKeyIndex returns the index of key node in dst mapping (index of key, not value). // Returns -1 when not found. func findMapKeyIndex(mapNode *yaml.Node, key string) int { if mapNode == nil || mapNode.Kind != yaml.MappingNode { return -1 } for i := 0; i+1 < len(mapNode.Content); i += 2 { if mapNode.Content[i] != nil && mapNode.Content[i].Value == key { return i } } return -1 } // appendPath appends a key to the path, returning a new slice to avoid modifying the original. func appendPath(path []string, key string) []string { if len(path) == 0 { return []string{key} } newPath := make([]string, len(path)+1) copy(newPath, path) newPath[len(path)] = key return newPath } // isKnownDefaultValue returns true if the given node at the specified path // represents a known default value that should not be written to the config file. // This prevents non-zero defaults from polluting the config. // // Credential connection-pool blocks (enabled, idle-conn-timeout, // max-idle-conns-per-host) are deliberately absent from the pointer-backed // zero-value whitelist below. The pruning here only applies to keys that are new // to the destination document: a block already present on disk is merged into its // existing node and survives. So an empty block (connection-pool: {} is the opt-in // for the default pool), an explicit enabled: false, and an explicit // max-idle-conns-per-host: 0 all persist when the operator wrote them by hand or // through the v8 management API, which writes the YAML tree directly. The // remaining gap is a block that only exists in an in-memory Config, for example one // an SDK embedder adds through sdk/config.SaveConfigPreserveComments to a file that // never carried it: pruning drops those zero-valued sub-keys. No management // endpoint mutates ProxyConnectionPool, so that path is reachable only from code. func isSensitiveWordsReplaceEntryPath(path []string) bool { for i, part := range path { if part == "sensitive-words-replace" && i < len(path)-1 { return true } } return false } func hasNonEmptySensitiveWordsReplace(node *yaml.Node) bool { if node == nil { return false } switch node.Kind { case yaml.MappingNode: for i := 0; i+1 < len(node.Content); i += 2 { k := node.Content[i] v := node.Content[i+1] if k != nil && k.Value == "sensitive-words-replace" && v != nil && v.Kind == yaml.MappingNode && len(v.Content) > 0 { return true } if hasNonEmptySensitiveWordsReplace(v) { return true } } case yaml.SequenceNode: for _, child := range node.Content { if hasNonEmptySensitiveWordsReplace(child) { return true } } } return false } func isKnownDefaultValue(path []string, node *yaml.Node) bool { if isPluginConfigsSubtreePath(path) || isSensitiveWordsReplaceEntryPath(path) { return false } if len(path) > 0 && path[len(path)-1] == "sensitive-words-replace" && node != nil && node.Kind == yaml.MappingNode && len(node.Content) > 0 { return false } if hasNonEmptySensitiveWordsReplace(node) { return false } if len(path) == 1 && path[0] == "plugins" && node != nil && node.Kind == yaml.MappingNode { configsIdx := findMapKeyIndex(node, "configs") if configsIdx >= 0 && configsIdx+1 < len(node.Content) { configsNode := node.Content[configsIdx+1] if configsNode != nil && configsNode.Kind == yaml.MappingNode && len(configsNode.Content) > 0 { return false } } } // Credential weights and retry overrides are pointer-backed: zero is explicit. if len(path) > 0 && (path[len(path)-1] == "weight" || path[len(path)-1] == "request-retry") && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!int" { return false } // session-date-pin-days is pointer-backed: only an explicit zero is // preserved; the default value must not pollute saves. if len(path) > 0 && path[len(path)-1] == "session-date-pin-days" && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!int" && node.Value == "0" { return false } // Pointer-backed booleans (such as cache-user-id and disable-cooling): explicit false is meaningful and must be preserved. if len(path) > 0 && (path[len(path)-1] == "cache-user-id" || path[len(path)-1] == "disable-cooling") && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!bool" { return false } // A parent mapping carrying an explicit session-date-pin-days value must // survive the zero-value check below, or the new key is never written. if node != nil && node.Kind == yaml.MappingNode { if pinIdx := findMapKeyIndex(node, "session-date-pin-days"); pinIdx >= 0 { return false } } // First check if it's a zero value if isZeroValueNode(node) { return true } // Match known non-zero defaults by exact dotted path. if len(path) == 0 { return false } fullPath := strings.Join(path, ".") // Check string defaults if node.Kind == yaml.ScalarNode && node.Tag == "!!str" { switch fullPath { case "pprof.addr": return node.Value == DefaultPprofAddr case "remote-management.panel-github-repository": return node.Value == DefaultPanelGitHubRepository case "plugins.dir": return node.Value == "plugins" case "routing.strategy": return node.Value == "round-robin" } } // Check integer defaults if node.Kind == yaml.ScalarNode && node.Tag == "!!int" { switch fullPath { case "error-logs-max-files": return node.Value == "10" case "claude.session-date-pin-days": return node.Value == strconv.Itoa(DefaultClaudeSessionDatePinDays) } } return false } // pruneKnownDefaultsInNewNode removes default-valued descendants from a new node // before it is appended into the destination YAML tree. func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) { if node == nil || isPluginConfigsSubtreePath(path) || isSensitiveWordsReplaceEntryPath(path) { return } if len(path) > 0 && path[len(path)-1] == "sensitive-words-replace" { return } switch node.Kind { case yaml.MappingNode: filtered := make([]*yaml.Node, 0, len(node.Content)) for i := 0; i+1 < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] if keyNode == nil || valueNode == nil { continue } childPath := appendPath(path, keyNode.Value) if isKnownDefaultValue(childPath, valueNode) { continue } pruneKnownDefaultsInNewNode(childPath, valueNode) if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) && len(valueNode.Content) == 0 { continue } filtered = append(filtered, keyNode, valueNode) } node.Content = filtered case yaml.SequenceNode: for _, child := range node.Content { pruneKnownDefaultsInNewNode(path, child) } } } // isZeroValueNode returns true if the YAML node represents a zero/default value // that should not be written as a new key to preserve config cleanliness. // For mappings and sequences, recursively checks if all children are zero values. func isZeroValueNode(node *yaml.Node) bool { if node == nil { return true } switch node.Kind { case yaml.ScalarNode: switch node.Tag { case "!!bool": return node.Value == "false" case "!!int", "!!float": return node.Value == "0" || node.Value == "0.0" case "!!str": return node.Value == "" case "!!null": return true } case yaml.SequenceNode: if len(node.Content) == 0 { return true } // Check if all elements are zero values for _, child := range node.Content { if !isZeroValueNode(child) { return false } } return true case yaml.MappingNode: if len(node.Content) == 0 { return true } // Check if all values are zero values (values are at odd indices) for i := 1; i < len(node.Content); i += 2 { if !isZeroValueNode(node.Content[i]) { return false } } return true } return false } // deepCopyNode creates a deep copy of a yaml.Node graph. func deepCopyNode(n *yaml.Node) *yaml.Node { return deepCopyNodeSeen(n, map[*yaml.Node]*yaml.Node{}) } func deepCopyNodeSeen(n *yaml.Node, seen map[*yaml.Node]*yaml.Node) *yaml.Node { if n == nil { return nil } if cp, ok := seen[n]; ok { return cp } cp := *n seen[n] = &cp if n.Alias != nil { cp.Alias = deepCopyNodeSeen(n.Alias, seen) } if len(n.Content) > 0 { cp.Content = make([]*yaml.Node, len(n.Content)) for i := range n.Content { cp.Content[i] = deepCopyNodeSeen(n.Content[i], seen) } } return &cp } // copyNodeShallow copies type/tag/value and resets content to match src, but // keeps the same destination node pointer to preserve parent relations/comments. func copyNodeShallow(dst, src *yaml.Node) { if dst == nil || src == nil { return } dst.Kind = src.Kind dst.Tag = src.Tag dst.Value = src.Value // Replace content with deep copy from src if len(src.Content) > 0 { dst.Content = make([]*yaml.Node, len(src.Content)) for i := range src.Content { dst.Content[i] = deepCopyNode(src.Content[i]) } } else { dst.Content = nil } } func reorderSequenceForMerge(dst, src *yaml.Node) { if dst == nil || src == nil { return } if len(dst.Content) == 0 { return } if len(src.Content) == 0 { return } original := append([]*yaml.Node(nil), dst.Content...) used := make([]bool, len(original)) ordered := make([]*yaml.Node, len(src.Content)) for i := range src.Content { if idx := matchSequenceElement(original, used, src.Content[i]); idx >= 0 { ordered[i] = original[idx] used[idx] = true } } dst.Content = ordered } func matchSequenceElement(original []*yaml.Node, used []bool, target *yaml.Node) int { if target == nil { return -1 } switch target.Kind { case yaml.MappingNode: id := sequenceElementIdentity(target) if id != "" { for i := range original { if used[i] || original[i] == nil || original[i].Kind != yaml.MappingNode { continue } if sequenceElementIdentity(original[i]) == id { return i } } } case yaml.ScalarNode: val := strings.TrimSpace(target.Value) if val != "" { for i := range original { if used[i] || original[i] == nil || original[i].Kind != yaml.ScalarNode { continue } if strings.TrimSpace(original[i].Value) == val { return i } } } default: } // Fallback to structural equality to preserve nodes lacking explicit identifiers. for i := range original { if used[i] || original[i] == nil { continue } if nodesStructurallyEqual(original[i], target) { return i } } return -1 } func sequenceElementIdentity(node *yaml.Node) string { if node == nil || node.Kind != yaml.MappingNode { return "" } identityKeys := []string{"id", "name", "alias", "api-key", "api_key", "apikey", "key", "provider", "model"} for _, k := range identityKeys { if v := mappingScalarValue(node, k); v != "" { return k + "=" + v } } for i := 0; i+1 < len(node.Content); i += 2 { keyNode := node.Content[i] valNode := node.Content[i+1] if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode { continue } val := strings.TrimSpace(valNode.Value) if val != "" { return strings.ToLower(strings.TrimSpace(keyNode.Value)) + "=" + val } } return "" } func mappingScalarValue(node *yaml.Node, key string) string { if node == nil || node.Kind != yaml.MappingNode { return "" } lowerKey := strings.ToLower(key) for i := 0; i+1 < len(node.Content); i += 2 { keyNode := node.Content[i] valNode := node.Content[i+1] if keyNode == nil || valNode == nil || valNode.Kind != yaml.ScalarNode { continue } if strings.ToLower(strings.TrimSpace(keyNode.Value)) == lowerKey { return strings.TrimSpace(valNode.Value) } } return "" } func nodesStructurallyEqual(a, b *yaml.Node) bool { if a == nil || b == nil { return a == b } if a.Kind != b.Kind { return false } switch a.Kind { case yaml.MappingNode: if len(a.Content) != len(b.Content) { return false } for i := 0; i+1 < len(a.Content); i += 2 { if !nodesStructurallyEqual(a.Content[i], b.Content[i]) { return false } if !nodesStructurallyEqual(a.Content[i+1], b.Content[i+1]) { return false } } return true case yaml.SequenceNode: if len(a.Content) != len(b.Content) { return false } for i := range a.Content { if !nodesStructurallyEqual(a.Content[i], b.Content[i]) { return false } } return true case yaml.ScalarNode: return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value) case yaml.AliasNode: return nodesStructurallyEqual(a.Alias, b.Alias) default: return strings.TrimSpace(a.Value) == strings.TrimSpace(b.Value) } } func removeMapKey(mapNode *yaml.Node, key string) { if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" { return } for i := 0; i+1 < len(mapNode.Content); i += 2 { if mapNode.Content[i] != nil && mapNode.Content[i].Value == key { mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...) return } } } 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 { return } dstIdx := findMapKeyIndex(dstRoot, key) if dstIdx < 0 || dstIdx+1 >= len(dstRoot.Content) { return } srcIdx := findMapKeyIndex(srcRoot, key) if srcIdx < 0 { // Keep explicit OAuth maps when the last channel is removed. Their presence // must survive saves and override legacy fields when restored to the v8 layout. if key == "oauth-excluded-models" || key == "oauth-model-alias" || key == "oauth-request-scoped-errors" || key == "oauth-settings" { dstRoot.Content[dstIdx+1] = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} return } removeMapKey(dstRoot, key) return } if srcIdx+1 >= len(srcRoot.Content) { return } srcVal := srcRoot.Content[srcIdx+1] dstVal := dstRoot.Content[dstIdx+1] if srcVal == nil { dstRoot.Content[dstIdx+1] = nil return } if srcVal.Kind != yaml.MappingNode { dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal) return } if dstVal == nil || dstVal.Kind != yaml.MappingNode { dstRoot.Content[dstIdx+1] = deepCopyNode(srcVal) return } pruneMissingMapKeys(dstVal, srcVal) } func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) { if dstMap == nil || srcMap == nil || dstMap.Kind != yaml.MappingNode || srcMap.Kind != yaml.MappingNode { return } keep := make(map[string]struct{}, len(srcMap.Content)/2) for i := 0; i+1 < len(srcMap.Content); i += 2 { keyNode := srcMap.Content[i] if keyNode == nil { continue } key := strings.TrimSpace(keyNode.Value) if key == "" { continue } keep[key] = struct{}{} } for i := 0; i+1 < len(dstMap.Content); { keyNode := dstMap.Content[i] if keyNode == nil { i += 2 continue } key := strings.TrimSpace(keyNode.Value) if _, ok := keep[key]; !ok { dstMap.Content = append(dstMap.Content[:i], dstMap.Content[i+2:]...) continue } i += 2 } } // shouldPruneNestedMappingKeys reports whether keys missing from src should be pruned from dst. // This is strictly scoped to credential-nested mappings such as "cloak" and "headers" under // known credential sequence paths to prevent stale deleted keys from persisting while leaving // all other mappings and root sections unaffected. // // Credential connection-pool blocks stay out of this list on purpose. Pruning only runs while // merging a document from an in-memory Config, and a removed pool sub-key can mean either // "delete it" or "inherit the group value" depending on the caller; guessing wrong would silently // change pooling for a credential. Hand-edited files and v8 management saves therefore keep a // sub-key until the whole block is removed. func shouldPruneNestedMappingKeys(path []string) bool { if len(path) < 2 { return false } parent := path[len(path)-2] last := path[len(path)-1] if last == "sensitive-words-replace" && (parent == "antigravity" || parent == "devin" || parent == "cloak") { return true } switch parent { case "claude-api-key": return last == "cloak" || last == "headers" case "codex-api-key", "gemini-api-key", "interactions-api-key", "xai-api-key", "meta-api-key", "vertex-api-key", "openai-compatibility": return last == "headers" default: return false } } // NormalizeCollectionNodeStyles forces YAML collections to use block notation, keeping // lists and maps readable. Empty sequences retain flow style ([]) so empty list markers // remain compact. It also clears double quotes from string mapping keys so JSON-derived // keys render as standard unquoted YAML keys. func NormalizeCollectionNodeStyles(node *yaml.Node) { if node == nil { return } switch node.Kind { case yaml.DocumentNode: for i := range node.Content { NormalizeCollectionNodeStyles(node.Content[i]) } case yaml.MappingNode: node.Style = 0 for i := 0; i < len(node.Content); i += 2 { key := node.Content[i] if key != nil && key.Kind == yaml.ScalarNode && key.Tag == "!!str" && key.Style == yaml.DoubleQuotedStyle { key.Style = 0 } NormalizeCollectionNodeStyles(key) if i+1 < len(node.Content) { NormalizeCollectionNodeStyles(node.Content[i+1]) } } case yaml.SequenceNode: if len(node.Content) == 0 { node.Style = yaml.FlowStyle } else { node.Style = 0 } for i := range node.Content { NormalizeCollectionNodeStyles(node.Content[i]) } default: // Scalars keep their existing style to preserve quoting } } func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) { if root == nil || root.Kind != yaml.MappingNode { return } idx := findMapKeyIndex(root, "openai-compatibility") if idx < 0 || idx+1 >= len(root.Content) { return } seq := root.Content[idx+1] if seq == nil || seq.Kind != yaml.SequenceNode { return } for i := range seq.Content { if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode { removeMapKey(seq.Content[i], "api-keys") } } } func removeRemovedIntegrationKeys(root *yaml.Node) { if root == nil || root.Kind != yaml.MappingNode { return } removeMapKey(root, "ampcode") removeMapKey(root, "amp-upstream-url") removeMapKey(root, "amp-upstream-api-key") removeMapKey(root, "amp-restrict-management-to-localhost") removeMapKey(root, "amp-model-mappings") } func removeLegacyGenerativeLanguageKeys(root *yaml.Node) { if root == nil || root.Kind != yaml.MappingNode { return } removeMapKey(root, "generative-language-api-key") } func removeLegacyAuthBlock(root *yaml.Node) { if root == nil || root.Kind != yaml.MappingNode { return } removeMapKey(root, "auth") } func isPluginConfigsPath(path []string) bool { return len(path) == 2 && path[0] == "plugins" && path[1] == "configs" } func isPluginConfigsSubtreePath(path []string) bool { return len(path) >= 2 && path[0] == "plugins" && path[1] == "configs" } func replacePluginConfigsSubtree(dstRoot, srcRoot *yaml.Node) { if dstRoot == nil || srcRoot == nil || dstRoot.Kind != yaml.MappingNode || srcRoot.Kind != yaml.MappingNode { return } srcPluginsIdx := findMapKeyIndex(srcRoot, "plugins") var srcConfigs *yaml.Node if srcPluginsIdx >= 0 && srcPluginsIdx+1 < len(srcRoot.Content) { srcPlugins := srcRoot.Content[srcPluginsIdx+1] if srcPlugins != nil && srcPlugins.Kind == yaml.MappingNode { srcConfigsIdx := findMapKeyIndex(srcPlugins, "configs") if srcConfigsIdx >= 0 && srcConfigsIdx+1 < len(srcPlugins.Content) { srcConfigs = srcPlugins.Content[srcConfigsIdx+1] } } } dstPluginsIdx := findMapKeyIndex(dstRoot, "plugins") if srcConfigs == nil || srcConfigs.Kind != yaml.MappingNode || len(srcConfigs.Content) == 0 { if dstPluginsIdx >= 0 && dstPluginsIdx+1 < len(dstRoot.Content) { dstPlugins := dstRoot.Content[dstPluginsIdx+1] if dstPlugins != nil && dstPlugins.Kind == yaml.MappingNode { removeMapKey(dstPlugins, "configs") } } return } copiedConfigs := deepCopyNode(srcConfigs) if dstPluginsIdx < 0 { dstPlugins := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} dstPlugins.Content = append(dstPlugins.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "configs"}, copiedConfigs, ) dstRoot.Content = append(dstRoot.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plugins"}, dstPlugins, ) return } if dstPluginsIdx+1 >= len(dstRoot.Content) { return } dstPlugins := dstRoot.Content[dstPluginsIdx+1] if dstPlugins == nil || dstPlugins.Kind != yaml.MappingNode { return } dstConfigsIdx := findMapKeyIndex(dstPlugins, "configs") if dstConfigsIdx >= 0 && dstConfigsIdx+1 < len(dstPlugins.Content) { if dstPlugins.Content[dstConfigsIdx+1] != nil { if copiedConfigs.HeadComment == "" { copiedConfigs.HeadComment = dstPlugins.Content[dstConfigsIdx+1].HeadComment } if copiedConfigs.LineComment == "" { copiedConfigs.LineComment = dstPlugins.Content[dstConfigsIdx+1].LineComment } if copiedConfigs.FootComment == "" { copiedConfigs.FootComment = dstPlugins.Content[dstConfigsIdx+1].FootComment } } dstPlugins.Content[dstConfigsIdx+1] = copiedConfigs } else { dstPlugins.Content = append(dstPlugins.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "configs"}, copiedConfigs, ) } }