Fix: Prevent security.yml from being overwritten during config migration (#1966)

This commit is contained in:
lxowalle
2026-03-24 20:02:58 +08:00
committed by GitHub
parent f2f6987f00
commit 8b6cbd9909
3 changed files with 263 additions and 0 deletions
+12
View File
@@ -1395,6 +1395,18 @@ func LoadConfig(path string) (*Config, error) {
if err != nil {
return nil, err
}
// Load existing security config and merge with migrated one to prevent data loss
existingSec, secErr := loadSecurityConfig(securityPath(path))
if secErr != nil {
logger.WarnF("failed to load existing security config during migration", map[string]any{"error": secErr})
}
if existingSec != nil && cfg.security != nil {
cfg.security = mergeSecurityConfig(existingSec, cfg.security)
// Re-apply the merged security config to update all channels and models
if err = applySecurityConfig(cfg, cfg.security); err != nil {
logger.WarnF("failed to re-apply merged security config during migration", map[string]any{"error": err})
}
}
defer func(cfg *Config) {
_ = SaveConfig(path, cfg)
}(cfg)
+115
View File
@@ -566,3 +566,118 @@ func TestMigration_Integration_ModelNameField(t *testing.T) {
t.Errorf("ModelFallbacks[0] = %q, want %q", cfg.Agents.Defaults.ModelFallbacks[0], "deepseek-chat")
}
}
// TestMigration_PreservesExistingSecurityConfig tests that when migrating from v0 to v1,
// existing .security.yml values (e.g., loaded from environment variables) are preserved
// and not overwritten by empty values from the legacy config.
func TestMigration_PreservesExistingSecurityConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
securityPath := filepath.Join(tmpDir, ".security.yml")
// Create a legacy config (version 0) with model_list and channel config
// The model_list doesn't have api_keys, they should come from existing .security.yml
legacyConfig := `{
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-4"
}
},
"model_list": [
{
"model_name": "openai",
"model": "openai/gpt-4"
}
],
"channels": {
"telegram": {
"enabled": true
}
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
},
"tools": {
"web": {"enabled": true}
},
"heartbeat": {
"enabled": true,
"interval": 30
},
"devices": {
"enabled": false
}
}`
// Create an existing .security.yml with values that might come from env vars
existingSecurity := `model_list:
openai:0:
api_keys:
- sk-existing-key-from-env
channels:
telegram:
token: existing-telegram-token-from-env
discord:
token: existing-discord-token-from-env
web:
brave:
api_keys:
- existing-brave-key
`
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil {
t.Fatalf("Failed to write legacy config: %v", err)
}
if err := os.WriteFile(securityPath, []byte(existingSecurity), 0o600); err != nil {
t.Fatalf("Failed to write existing security config: %v", err)
}
// Load the config - this should trigger migration
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig failed: %v", err)
}
// Verify that the migrated config has the existing security values
// Telegram token should be preserved
if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" {
t.Errorf("Telegram token was overwritten: got %q, want %q",
cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env")
}
// Discord token should be preserved (even though legacy config didn't have it)
if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" {
t.Errorf("Discord token was overwritten: got %q, want %q",
cfg.Channels.Discord.Token(), "existing-discord-token-from-env")
}
// Model API key should be preserved
if cfg.ModelList[0].APIKey() != "sk-existing-key-from-env" {
t.Errorf("Model API key was overwritten: got %q, want %q",
cfg.ModelList[0].APIKey(), "sk-existing-key-from-env")
}
// Brave API key should be preserved
if cfg.Tools.Web.Brave.APIKey() != "existing-brave-key" {
t.Errorf("Brave API key was overwritten: got %q, want %q",
cfg.Tools.Web.Brave.APIKey(), "existing-brave-key")
}
// Reload the security config from disk to verify it wasn't corrupted
reloadedSec, err := loadSecurityConfig(securityPath)
if err != nil {
t.Fatalf("Failed to reload security config: %v", err)
}
if reloadedSec.Channels.Telegram == nil ||
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
t.Error("Telegram token not preserved in .security.yml file")
}
if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" {
t.Error("Discord token not preserved in .security.yml file")
}
}
+136
View File
@@ -244,6 +244,142 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
}
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
// This is used during config migration to preserve existing security data while adding new entries.
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
if existing == nil {
return normalizeSecurityConfig(newer)
}
if newer == nil {
return normalizeSecurityConfig(existing)
}
result := normalizeSecurityConfig(nil)
// Merge ModelList: prefer newer if it has keys, otherwise use existing
for k, v := range existing.ModelList {
result.ModelList[k] = v
}
for k, v := range newer.ModelList {
if len(v.APIKeys) > 0 {
result.ModelList[k] = v
}
}
// Merge Channels
if existing.Channels != nil {
result.Channels = existing.Channels
}
if newer.Channels != nil {
if result.Channels == nil {
result.Channels = &ChannelsSecurity{}
}
mergeChannelsSecurity(result.Channels, newer.Channels)
}
// Merge Web
if existing.Web != nil {
result.Web = existing.Web
}
if newer.Web != nil {
if result.Web == nil {
result.Web = &WebToolsSecurity{}
}
mergeWebToolsSecurity(result.Web, newer.Web)
}
// Merge Skills
if existing.Skills != nil {
result.Skills = existing.Skills
}
if newer.Skills != nil {
if result.Skills == nil {
result.Skills = &SkillsSecurity{}
}
mergeSkillsSecurity(result.Skills, newer.Skills)
}
return result
}
func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
if src.Telegram != nil && src.Telegram.Token != "" {
dst.Telegram = src.Telegram
}
if src.Feishu != nil &&
(src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") {
dst.Feishu = src.Feishu
}
if src.Discord != nil && src.Discord.Token != "" {
dst.Discord = src.Discord
}
if src.Weixin != nil && src.Weixin.Token != "" {
dst.Weixin = src.Weixin
}
if src.QQ != nil && src.QQ.AppSecret != "" {
dst.QQ = src.QQ
}
if src.DingTalk != nil && src.DingTalk.ClientSecret != "" {
dst.DingTalk = src.DingTalk
}
if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") {
dst.Slack = src.Slack
}
if src.Matrix != nil && src.Matrix.AccessToken != "" {
dst.Matrix = src.Matrix
}
if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") {
dst.LINE = src.LINE
}
if src.OneBot != nil && src.OneBot.AccessToken != "" {
dst.OneBot = src.OneBot
}
if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") {
dst.WeCom = src.WeCom
}
if src.WeComApp != nil &&
(src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") {
dst.WeComApp = src.WeComApp
}
if src.WeComAIBot != nil &&
(src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") {
dst.WeComAIBot = src.WeComAIBot
}
if src.Pico != nil && src.Pico.Token != "" {
dst.Pico = src.Pico
}
if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") {
dst.IRC = src.IRC
}
}
func mergeWebToolsSecurity(dst, src *WebToolsSecurity) {
if src.Brave != nil && len(src.Brave.APIKeys) > 0 {
dst.Brave = src.Brave
}
if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 {
dst.Tavily = src.Tavily
}
if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 {
dst.Perplexity = src.Perplexity
}
if src.GLMSearch != nil && src.GLMSearch.APIKey != "" {
dst.GLMSearch = src.GLMSearch
}
if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" {
dst.BaiduSearch = src.BaiduSearch
}
}
func mergeSkillsSecurity(dst, src *SkillsSecurity) {
if src.Github != nil && src.Github.Token != "" {
dst.Github = src.Github
}
if src.ClawHub != nil && src.ClawHub.AuthToken != "" {
dst.ClawHub = src.ClawHub
}
}
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
// Computed once on first access via sync.Once.