From cf9e0496f7ec303c638f78b84dc03b59f3af5ecc Mon Sep 17 00:00:00 2001 From: Cytown Date: Tue, 24 Mar 2026 10:26:11 +0800 Subject: [PATCH 1/3] fix launcher can't save model api_key issue (#1928) * fix launcher can't save model api_key issue * add backup for old data before migrate config and fix migrate to empty security issue --- pkg/config/config.go | 304 ++++++++++-------- pkg/config/config_old.go | 616 +++++++++++++++++++++--------------- pkg/config/config_test.go | 6 +- pkg/config/defaults.go | 5 +- pkg/config/security.go | 6 +- pkg/config/security_test.go | 4 +- pkg/fileutil/file.go | 8 + web/backend/api/models.go | 18 +- 8 files changed, 552 insertions(+), 415 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 051670437..f0d9aa580 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1350,11 +1350,14 @@ type MCPConfig struct { } func LoadConfig(path string) (*Config, error) { + logger.Debugf("loading config from %s", path) data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { + logger.WarnF("config file not found, using default config", map[string]any{"path": path}) return DefaultConfig(), nil } + logger.Errorf("failed to read config file: %v", err) return nil, err } @@ -1366,6 +1369,7 @@ func LoadConfig(path string) (*Config, error) { return nil, fmt.Errorf("failed to detect config version: %w", e) } if len(data) <= 10 { + logger.Warn(fmt.Sprintf("content is [%s]", string(data))) return DefaultConfig().WithSecurity(&SecurityConfig{}), nil } @@ -1381,36 +1385,39 @@ func LoadConfig(path string) (*Config, error) { } cfg, e = v.Migrate() if e != nil { - logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) return nil, e } - logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) - defer func() { + logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) + err = makeBackup(path) + if err != nil { + return nil, err + } + defer func(cfg *Config) { _ = SaveConfig(path, cfg) - }() + }(cfg) case CurrentVersion: // Current version cfg, err = loadConfig(data) if err != nil { return nil, err } + // Load security configuration + securityPath := securityPath(path) + sec, err := loadSecurityConfig(securityPath) + if err != nil { + return nil, fmt.Errorf("failed to load security config: %w", err) + } + + // Apply security references from .security.yml BEFORE resolveAPIKeys + // This resolves ref: references to actual values + if err := applySecurityConfig(cfg, sec); err != nil { + return nil, fmt.Errorf("failed to apply security config: %w", err) + } default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } - // Load security configuration - securityPath := securityPath(path) - sec, err := loadSecurityConfig(securityPath) - if err != nil { - return nil, fmt.Errorf("failed to load security config: %w", err) - } - - // Apply security references from .security.yml BEFORE resolveAPIKeys - // This resolves ref: references to actual values - if err := applySecurityConfig(cfg, sec); err != nil { - return nil, fmt.Errorf("failed to apply security config: %w", err) - } - if passphrase := credential.PassphraseProvider(); passphrase != "" { for _, m := range cfg.ModelList { for _, k := range m.apiKeys { @@ -1462,6 +1469,19 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +func makeBackup(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + // Create backup of the config file before migration + bakPath := path + ".bak" + if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil { + logger.ErrorF("failed to create config backup", map[string]any{"error": err}) + return fmt.Errorf("failed to create config backup: %w", err) + } + return nil +} + func copyArray[T any](dst, src *[]T) { *dst = make([]T, len(*src)) copy(*dst, *src) @@ -1474,32 +1494,36 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { return nil } - if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 { - copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys) + if sec.Web != nil { + if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 { + copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys) + } + + if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 { + copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys) + } + + if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 { + copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys) + } + + if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" { + cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey + } + + if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" { + cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey + } } - if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 { - copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys) - } + if sec.Skills != nil { + if sec.Skills.Github != nil && sec.Skills.Github.Token != "" { + cfg.Tools.Skills.Github.token = sec.Skills.Github.Token + } - if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 { - copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys) - } - - if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" { - cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey - } - - if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" { - cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey - } - - if sec.Skills.Github != nil && sec.Skills.Github.Token != "" { - cfg.Tools.Skills.Github.token = sec.Skills.Github.Token - } - - if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" { - cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken + if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" { + cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken + } } names := toNameIndex(cfg.ModelList) @@ -1521,126 +1545,128 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { } } - // Handle Telegram token - if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" { - cfg.Channels.Telegram.token = sec.Channels.Telegram.Token - } + if sec.Channels != nil { + // Handle Telegram token + if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" { + cfg.Channels.Telegram.token = sec.Channels.Telegram.Token + } - // Handle Feishu credentials - if sec.Channels.Feishu != nil { - if sec.Channels.Feishu.AppSecret != "" { - cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret + // Handle Feishu credentials + if sec.Channels.Feishu != nil { + if sec.Channels.Feishu.AppSecret != "" { + cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret + } + if sec.Channels.Feishu.EncryptKey != "" { + cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey + } + if sec.Channels.Feishu.VerificationToken != "" { + cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken + } } - if sec.Channels.Feishu.EncryptKey != "" { - cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey - } - if sec.Channels.Feishu.VerificationToken != "" { - cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken - } - } - // Handle Discord token - if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" { - cfg.Channels.Discord.token = sec.Channels.Discord.Token - } + // Handle Discord token + if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" { + cfg.Channels.Discord.token = sec.Channels.Discord.Token + } - // Handle Weixin token - if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" { - cfg.Channels.Weixin.token = sec.Channels.Weixin.Token - } + // Handle Weixin token + if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" { + cfg.Channels.Weixin.token = sec.Channels.Weixin.Token + } - // Handle DingTalk client secret - if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" { - cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret - } + // Handle DingTalk client secret + if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" { + cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret + } - // Handle Slack tokens - if sec.Channels.Slack != nil { - if sec.Channels.Slack.BotToken != "" { - cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken + // Handle Slack tokens + if sec.Channels.Slack != nil { + if sec.Channels.Slack.BotToken != "" { + cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken + } + if sec.Channels.Slack.AppToken != "" { + cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken + } } - if sec.Channels.Slack.AppToken != "" { - cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken - } - } - // Handle Matrix access token - if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" { - cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken - } + // Handle Matrix access token + if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" { + cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken + } - // Handle LINE credentials - if sec.Channels.LINE != nil { - if sec.Channels.LINE.ChannelSecret != "" { - cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret + // Handle LINE credentials + if sec.Channels.LINE != nil { + if sec.Channels.LINE.ChannelSecret != "" { + cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret + } + if sec.Channels.LINE.ChannelAccessToken != "" { + cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken + } } - if sec.Channels.LINE.ChannelAccessToken != "" { - cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken - } - } - // Handle OneBot access token - if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" { - cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken - } + // Handle OneBot access token + if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" { + cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken + } - // Handle WeCom token and encoding key - if sec.Channels.WeCom != nil { - if sec.Channels.WeCom.Token != "" { - cfg.Channels.WeCom.token = sec.Channels.WeCom.Token + // Handle WeCom token and encoding key + if sec.Channels.WeCom != nil { + if sec.Channels.WeCom.Token != "" { + cfg.Channels.WeCom.token = sec.Channels.WeCom.Token + } + if sec.Channels.WeCom.EncodingAESKey != "" { + cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey + } } - if sec.Channels.WeCom.EncodingAESKey != "" { - cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey - } - } - // Handle WeCom App credentials - if sec.Channels.WeComApp != nil { - if sec.Channels.WeComApp.CorpSecret != "" { - cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret + // Handle WeCom App credentials + if sec.Channels.WeComApp != nil { + if sec.Channels.WeComApp.CorpSecret != "" { + cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret + } + if sec.Channels.WeComApp.Token != "" { + cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token + } + if sec.Channels.WeComApp.EncodingAESKey != "" { + cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey + } } - if sec.Channels.WeComApp.Token != "" { - cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token - } - if sec.Channels.WeComApp.EncodingAESKey != "" { - cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey - } - } - // Handle WeCom AI Bot credentials - if sec.Channels.WeComAIBot != nil { - if sec.Channels.WeComAIBot.Token != "" { - cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token + // Handle WeCom AI Bot credentials + if sec.Channels.WeComAIBot != nil { + if sec.Channels.WeComAIBot.Token != "" { + cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token + } + if sec.Channels.WeComAIBot.EncodingAESKey != "" { + cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey + } + if sec.Channels.WeComAIBot.Secret != "" { + cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret + } } - if sec.Channels.WeComAIBot.EncodingAESKey != "" { - cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey - } - if sec.Channels.WeComAIBot.Secret != "" { - cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret - } - } - // Handle Pico channel token - if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" { - cfg.Channels.Pico.token = sec.Channels.Pico.Token - } + // Handle Pico channel token + if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" { + cfg.Channels.Pico.token = sec.Channels.Pico.Token + } - // Handle IRC passwords - if sec.Channels.IRC != nil { - if sec.Channels.IRC.Password != "" { - cfg.Channels.IRC.password = sec.Channels.IRC.Password + // Handle IRC passwords + if sec.Channels.IRC != nil { + if sec.Channels.IRC.Password != "" { + cfg.Channels.IRC.password = sec.Channels.IRC.Password + } + if sec.Channels.IRC.NickServPassword != "" { + cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword + } + if sec.Channels.IRC.SASLPassword != "" { + cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword + } } - if sec.Channels.IRC.NickServPassword != "" { - cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword - } - if sec.Channels.IRC.SASLPassword != "" { - cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword - } - } - // Handle QQ app secret - if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" { - cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret + // Handle QQ app secret + if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" { + cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret + } } cfg.security = sec diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index c7c7f0028..01909f5a9 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -5,7 +5,9 @@ package config -import "encoding/json" +import ( + "encoding/json" +) type agentDefaultsV0 struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` @@ -139,21 +141,21 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) Pico: pico, IRC: irc, }, ChannelsSecurity{ - Telegram: &telegramSecurity, - Feishu: &feishuSecurity, - Discord: &discordSecurity, - QQ: &qqSecurity, - Weixin: &weixinSecurity, - DingTalk: &dingtalkSecurity, - Slack: &slackSecurity, - Matrix: &matrixSecurity, - LINE: &lineSecurity, - OneBot: &onebotSecurity, - WeCom: &wecomSecurity, - WeComApp: &wecomappSecurity, - WeComAIBot: &wecomaibotSecurity, - Pico: &picoSecurity, - IRC: &ircSecurity, + Telegram: telegramSecurity, + Feishu: feishuSecurity, + Discord: discordSecurity, + QQ: qqSecurity, + Weixin: weixinSecurity, + DingTalk: dingtalkSecurity, + Slack: slackSecurity, + Matrix: matrixSecurity, + LINE: lineSecurity, + OneBot: onebotSecurity, + WeCom: wecomSecurity, + WeComApp: wecomappSecurity, + WeComAIBot: wecomaibotSecurity, + Pico: picoSecurity, + IRC: ircSecurity, } } @@ -169,19 +171,23 @@ type qqConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } -func (v *qqConfigV0) ToQQConfig() (QQConfig, QQSecurity) { - return QQConfig{ - Enabled: v.Enabled, - AppID: v.AppID, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - MaxMessageLength: v.MaxMessageLength, - MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB, - SendMarkdown: v.SendMarkdown, - ReasoningChannelID: v.ReasoningChannelID, - }, QQSecurity{ +func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) { + var sec *QQSecurity + if v.AppSecret != "" { + sec = &QQSecurity{ AppSecret: v.AppSecret, } + } + return QQConfig{ + Enabled: v.Enabled, + AppID: v.AppID, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + MaxMessageLength: v.MaxMessageLength, + MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB, + SendMarkdown: v.SendMarkdown, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type telegramConfigV0 struct { @@ -197,21 +203,25 @@ type telegramConfigV0 struct { UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } -func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, TelegramSecurity) { - return TelegramConfig{ - Enabled: v.Enabled, - token: v.Token, - BaseURL: v.BaseURL, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - UseMarkdownV2: v.UseMarkdownV2, - }, TelegramSecurity{ +func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) { + var sec *TelegramSecurity + if v.Token != "" { + sec = &TelegramSecurity{ Token: v.Token, } + } + return TelegramConfig{ + Enabled: v.Enabled, + token: v.Token, + BaseURL: v.BaseURL, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + UseMarkdownV2: v.UseMarkdownV2, + }, sec } type feishuConfigV0 struct { @@ -228,20 +238,24 @@ type feishuConfigV0 struct { IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } -func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, FeishuSecurity) { - return FeishuConfig{ - Enabled: v.Enabled, - AppID: v.AppID, - appSecret: v.AppSecret, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, FeishuSecurity{ +func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, *FeishuSecurity) { + var sec *FeishuSecurity + if v.AppSecret != "" || v.EncryptKey != "" || v.VerificationToken != "" { + sec = &FeishuSecurity{ AppSecret: v.AppSecret, EncryptKey: v.EncryptKey, VerificationToken: v.VerificationToken, } + } + return FeishuConfig{ + Enabled: v.Enabled, + AppID: v.AppID, + appSecret: v.AppSecret, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type discordConfigV0 struct { @@ -256,20 +270,24 @@ type discordConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } -func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, DiscordSecurity) { - return DiscordConfig{ - Enabled: v.Enabled, - token: v.Token, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - MentionOnly: v.MentionOnly, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, DiscordSecurity{ +func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) { + var sec *DiscordSecurity + if v.Token != "" { + sec = &DiscordSecurity{ Token: v.Token, } + } + return DiscordConfig{ + Enabled: v.Enabled, + token: v.Token, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + MentionOnly: v.MentionOnly, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type maixcamConfigV0 struct { @@ -299,17 +317,21 @@ type dingtalkConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } -func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, DingTalkSecurity) { - return DingTalkConfig{ - Enabled: v.Enabled, - ClientID: v.ClientID, - clientSecret: v.ClientSecret, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - ReasoningChannelID: v.ReasoningChannelID, - }, DingTalkSecurity{ +func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) { + var sec *DingTalkSecurity + if v.ClientSecret != "" { + sec = &DingTalkSecurity{ ClientSecret: v.ClientSecret, } + } + return DingTalkConfig{ + Enabled: v.Enabled, + ClientID: v.ClientID, + clientSecret: v.ClientSecret, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type slackConfigV0 struct { @@ -323,20 +345,24 @@ type slackConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } -func (v *slackConfigV0) ToSlackConfig() (SlackConfig, SlackSecurity) { - return SlackConfig{ - Enabled: v.Enabled, - botToken: v.BotToken, - appToken: v.AppToken, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, SlackSecurity{ +func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) { + var sec *SlackSecurity + if v.BotToken != "" || v.AppToken != "" { + sec = &SlackSecurity{ BotToken: v.BotToken, AppToken: v.AppToken, } + } + return SlackConfig{ + Enabled: v.Enabled, + botToken: v.BotToken, + appToken: v.AppToken, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type matrixConfigV0 struct { @@ -353,22 +379,26 @@ type matrixConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } -func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, MatrixSecurity) { - return MatrixConfig{ - Enabled: v.Enabled, - Homeserver: v.Homeserver, - UserID: v.UserID, - accessToken: v.AccessToken, - DeviceID: v.DeviceID, - JoinOnInvite: v.JoinOnInvite, - MessageFormat: v.MessageFormat, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, MatrixSecurity{ +func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) { + var sec *MatrixSecurity + if v.AccessToken != "" { + sec = &MatrixSecurity{ AccessToken: v.AccessToken, } + } + return MatrixConfig{ + Enabled: v.Enabled, + Homeserver: v.Homeserver, + UserID: v.UserID, + accessToken: v.AccessToken, + DeviceID: v.DeviceID, + JoinOnInvite: v.JoinOnInvite, + MessageFormat: v.MessageFormat, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type lineConfigV0 struct { @@ -385,23 +415,27 @@ type lineConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` } -func (v *lineConfigV0) ToLINEConfig() (LINEConfig, LINESecurity) { - return LINEConfig{ - Enabled: v.Enabled, - channelSecret: v.ChannelSecret, - channelAccessToken: v.ChannelAccessToken, - WebhookHost: v.WebhookHost, - WebhookPort: v.WebhookPort, - WebhookPath: v.WebhookPath, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, LINESecurity{ +func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) { + var sec *LINESecurity + if v.ChannelSecret != "" || v.ChannelAccessToken != "" { + sec = &LINESecurity{ ChannelSecret: v.ChannelSecret, ChannelAccessToken: v.ChannelAccessToken, } + } + return LINEConfig{ + Enabled: v.Enabled, + channelSecret: v.ChannelSecret, + channelAccessToken: v.ChannelAccessToken, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type onebotConfigV0 struct { @@ -417,21 +451,25 @@ type onebotConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` } -func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, OneBotSecurity) { - return OneBotConfig{ - Enabled: v.Enabled, - WSUrl: v.WSUrl, - accessToken: v.AccessToken, - ReconnectInterval: v.ReconnectInterval, - GroupTriggerPrefix: v.GroupTriggerPrefix, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - Placeholder: v.Placeholder, - ReasoningChannelID: v.ReasoningChannelID, - }, OneBotSecurity{ +func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) { + var sec *OneBotSecurity + if v.AccessToken != "" { + sec = &OneBotSecurity{ AccessToken: v.AccessToken, } + } + return OneBotConfig{ + Enabled: v.Enabled, + WSUrl: v.WSUrl, + accessToken: v.AccessToken, + ReconnectInterval: v.ReconnectInterval, + GroupTriggerPrefix: v.GroupTriggerPrefix, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + Placeholder: v.Placeholder, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type wecomConfigV0 struct { @@ -448,23 +486,27 @@ type wecomConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"` } -func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, WeComSecurity) { - return WeComConfig{ - Enabled: v.Enabled, - token: v.Token, - encodingAESKey: v.EncodingAESKey, - WebhookURL: v.WebhookURL, - WebhookHost: v.WebhookHost, - WebhookPort: v.WebhookPort, - WebhookPath: v.WebhookPath, - AllowFrom: v.AllowFrom, - ReplyTimeout: v.ReplyTimeout, - GroupTrigger: v.GroupTrigger, - ReasoningChannelID: v.ReasoningChannelID, - }, WeComSecurity{ +func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) { + var sec *WeComSecurity + if v.Token != "" || v.EncodingAESKey != "" { + sec = &WeComSecurity{ Token: v.Token, EncodingAESKey: v.EncodingAESKey, } + } + return WeComConfig{ + Enabled: v.Enabled, + token: v.Token, + encodingAESKey: v.EncodingAESKey, + WebhookURL: v.WebhookURL, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type weixinConfigV0 struct { @@ -477,18 +519,22 @@ type weixinConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` } -func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, WeixinSecurity) { - return WeixinConfig{ - Enabled: v.Enabled, - token: v.Token, - BaseURL: v.BaseURL, - CDNBaseURL: v.CDNBaseURL, - Proxy: v.Proxy, - AllowFrom: v.AllowFrom, - ReasoningChannelID: v.ReasoningChannelID, - }, WeixinSecurity{ +func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) { + var sec *WeixinSecurity + if v.Token != "" { + sec = &WeixinSecurity{ Token: v.Token, } + } + return WeixinConfig{ + Enabled: v.Enabled, + token: v.Token, + BaseURL: v.BaseURL, + CDNBaseURL: v.CDNBaseURL, + Proxy: v.Proxy, + AllowFrom: v.AllowFrom, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type wecomappConfigV0 struct { @@ -507,26 +553,30 @@ type wecomappConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } -func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, WeComAppSecurity) { - return WeComAppConfig{ - Enabled: v.Enabled, - CorpID: v.CorpID, - corpSecret: v.CorpSecret, - AgentID: v.AgentID, - token: v.Token, - encodingAESKey: v.EncodingAESKey, - WebhookHost: v.WebhookHost, - WebhookPort: v.WebhookPort, - WebhookPath: v.WebhookPath, - AllowFrom: v.AllowFrom, - ReplyTimeout: v.ReplyTimeout, - GroupTrigger: v.GroupTrigger, - ReasoningChannelID: v.ReasoningChannelID, - }, WeComAppSecurity{ +func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, *WeComAppSecurity) { + var sec *WeComAppSecurity + if v.CorpSecret != "" || v.Token != "" || v.EncodingAESKey != "" { + sec = &WeComAppSecurity{ CorpSecret: v.CorpSecret, Token: v.Token, EncodingAESKey: v.EncodingAESKey, } + } + return WeComAppConfig{ + Enabled: v.Enabled, + CorpID: v.CorpID, + corpSecret: v.CorpSecret, + AgentID: v.AgentID, + token: v.Token, + encodingAESKey: v.EncodingAESKey, + WebhookHost: v.WebhookHost, + WebhookPort: v.WebhookPort, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + GroupTrigger: v.GroupTrigger, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type wecomaibotConfigV0 struct { @@ -542,20 +592,24 @@ type wecomaibotConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"` } -func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, WeComAIBotSecurity) { - return WeComAIBotConfig{ - Enabled: v.Enabled, - WebhookPath: v.WebhookPath, - AllowFrom: v.AllowFrom, - ReplyTimeout: v.ReplyTimeout, - MaxSteps: v.MaxSteps, - WelcomeMessage: v.WelcomeMessage, - ReasoningChannelID: v.ReasoningChannelID, - }, WeComAIBotSecurity{ +func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, *WeComAIBotSecurity) { + var sec *WeComAIBotSecurity + if v.Token != "" || v.Secret != "" || v.EncodingAESKey != "" { + sec = &WeComAIBotSecurity{ Token: v.Token, Secret: v.Secret, EncodingAESKey: v.EncodingAESKey, } + } + return WeComAIBotConfig{ + Enabled: v.Enabled, + WebhookPath: v.WebhookPath, + AllowFrom: v.AllowFrom, + ReplyTimeout: v.ReplyTimeout, + MaxSteps: v.MaxSteps, + WelcomeMessage: v.WelcomeMessage, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type picoConfigV0 struct { @@ -571,21 +625,25 @@ type picoConfigV0 struct { Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } -func (v *picoConfigV0) ToPicoConfig() (PicoConfig, PicoSecurity) { - return PicoConfig{ - Enabled: v.Enabled, - token: v.Token, - AllowTokenQuery: v.AllowTokenQuery, - AllowOrigins: v.AllowOrigins, - PingInterval: v.PingInterval, - ReadTimeout: v.ReadTimeout, - WriteTimeout: v.WriteTimeout, - MaxConnections: v.MaxConnections, - AllowFrom: v.AllowFrom, - Placeholder: v.Placeholder, - }, PicoSecurity{ +func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) { + var sec *PicoSecurity + if v.Token != "" { + sec = &PicoSecurity{ Token: v.Token, } + } + return PicoConfig{ + Enabled: v.Enabled, + token: v.Token, + AllowTokenQuery: v.AllowTokenQuery, + AllowOrigins: v.AllowOrigins, + PingInterval: v.PingInterval, + ReadTimeout: v.ReadTimeout, + WriteTimeout: v.WriteTimeout, + MaxConnections: v.MaxConnections, + AllowFrom: v.AllowFrom, + Placeholder: v.Placeholder, + }, sec } type ircConfigV0 struct { @@ -607,29 +665,33 @@ type ircConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` } -func (v *ircConfigV0) ToIRCConfig() (IRCConfig, IRCSecurity) { - return IRCConfig{ - Enabled: v.Enabled, - Server: v.Server, - TLS: v.TLS, - Nick: v.Nick, - User: v.User, - RealName: v.RealName, - password: v.Password, - nickServPassword: v.NickServPassword, - SASLUser: v.SASLUser, - saslPassword: v.SASLPassword, - Channels: v.Channels, - RequestCaps: v.RequestCaps, - AllowFrom: v.AllowFrom, - GroupTrigger: v.GroupTrigger, - Typing: v.Typing, - ReasoningChannelID: v.ReasoningChannelID, - }, IRCSecurity{ +func (v *ircConfigV0) ToIRCConfig() (IRCConfig, *IRCSecurity) { + var sec *IRCSecurity + if v.Password != "" || v.NickServPassword != "" || v.SASLPassword != "" { + sec = &IRCSecurity{ Password: v.Password, NickServPassword: v.NickServPassword, SASLPassword: v.SASLPassword, } + } + return IRCConfig{ + Enabled: v.Enabled, + Server: v.Server, + TLS: v.TLS, + Nick: v.Nick, + User: v.User, + RealName: v.RealName, + password: v.Password, + nickServPassword: v.NickServPassword, + SASLUser: v.SASLUser, + saslPassword: v.SASLPassword, + Channels: v.Channels, + RequestCaps: v.RequestCaps, + AllowFrom: v.AllowFrom, + GroupTrigger: v.GroupTrigger, + Typing: v.Typing, + ReasoningChannelID: v.ReasoningChannelID, + }, sec } type providersConfigV0 struct { @@ -783,7 +845,7 @@ func (c *configV0) Migrate() (*Config, error) { cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig() cfg.Tools.Cron = c.Tools.Cron cfg.Tools.Exec = c.Tools.Exec - var secSkills SkillsSecurity + var secSkills *SkillsSecurity cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig() cfg.Tools.MediaCleanup = c.Tools.MediaCleanup cfg.Tools.MCP = c.Tools.MCP @@ -835,16 +897,18 @@ func (c *configV0) Migrate() (*Config, error) { for i, m := range c.ModelList { // Merge APIKey and APIKeys, deduplicating mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys) - secModels[names[i]] = ModelSecurityEntry{ - APIKeys: mergedKeys, + if len(mergedKeys) > 0 { + secModels[names[i]] = ModelSecurityEntry{ + APIKeys: mergedKeys, + } } } } cfg.WithSecurity(&SecurityConfig{ ModelList: secModels, - Channels: secChannels, - Web: secWeb, + Channels: &secChannels, + Web: &secWeb, Skills: secSkills, }) cfg.Version = CurrentVersion @@ -873,13 +937,17 @@ type braveConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } -func (v *braveConfigV0) ToBraveConfig() (BraveConfig, BraveSecurity) { - return BraveConfig{ - Enabled: v.Enabled, - MaxResults: v.MaxResults, - }, BraveSecurity{ +func (v *braveConfigV0) ToBraveConfig() (BraveConfig, *BraveSecurity) { + var sec *BraveSecurity + if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 { + sec = &BraveSecurity{ APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), } + } + return BraveConfig{ + Enabled: v.Enabled, + MaxResults: v.MaxResults, + }, sec } type tavilyConfigV0 struct { @@ -890,14 +958,18 @@ type tavilyConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } -func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, TavilySecurity) { - return TavilyConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - MaxResults: v.MaxResults, - }, TavilySecurity{ - APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), +func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) { + var sec *TavilySecurity + if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 { + sec = &TavilySecurity{ + APIKeys: k, } + } + return TavilyConfig{ + Enabled: v.Enabled, + BaseURL: v.BaseURL, + MaxResults: v.MaxResults, + }, sec } type perplexityConfigV0 struct { @@ -907,13 +979,17 @@ type perplexityConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } -func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, PerplexitySecurity) { - return PerplexityConfig{ - Enabled: v.Enabled, - MaxResults: v.MaxResults, - }, PerplexitySecurity{ - APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys), +func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) { + var sec *PerplexitySecurity + if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 { + sec = &PerplexitySecurity{ + APIKeys: k, } + } + return PerplexityConfig{ + Enabled: v.Enabled, + MaxResults: v.MaxResults, + }, sec } type glmSearchConfigV0 struct { @@ -923,15 +999,19 @@ type glmSearchConfigV0 struct { SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` } -func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, GLMSearchSecurity) { - return GLMSearchConfig{ - Enabled: v.Enabled, - apiKey: v.APIKey, - BaseURL: v.BaseURL, - SearchEngine: v.SearchEngine, - }, GLMSearchSecurity{ +func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) { + var sec *GLMSearchSecurity + if v.APIKey != "" { + sec = &GLMSearchSecurity{ APIKey: v.APIKey, } + } + return GLMSearchConfig{ + Enabled: v.Enabled, + apiKey: v.APIKey, + BaseURL: v.BaseURL, + SearchEngine: v.SearchEngine, + }, sec } func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) { @@ -954,10 +1034,10 @@ func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) Format: v.Format, PrivateHostWhitelist: v.PrivateHostWhitelist, }, WebToolsSecurity{ - Brave: &braveSecurity, - Tavily: &tavilySecurity, - Perplexity: &perplexitySecurity, - GLMSearch: &glmSearchSecurity, + Brave: braveSecurity, + Tavily: tavilySecurity, + Perplexity: perplexitySecurity, + GLMSearch: glmSearchSecurity, } } @@ -981,16 +1061,20 @@ type clawHubRegistryConfigV0 struct { SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` } -func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, ClawHubSecurity) { - return ClawHubRegistryConfig{ - Enabled: v.Enabled, - BaseURL: v.BaseURL, - authToken: v.AuthToken, - SearchPath: v.SearchPath, - SkillsPath: v.SkillsPath, - }, ClawHubSecurity{ +func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) { + var sec *ClawHubSecurity + if v.AuthToken != "" { + sec = &ClawHubSecurity{ AuthToken: v.AuthToken, } + } + return ClawHubRegistryConfig{ + Enabled: v.Enabled, + BaseURL: v.BaseURL, + authToken: v.AuthToken, + SearchPath: v.SearchPath, + SkillsPath: v.SkillsPath, + }, sec } type skillsGithubConfigV0 struct { @@ -998,13 +1082,17 @@ type skillsGithubConfigV0 struct { Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } -func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, GithubSecurity) { - return SkillsGithubConfig{ - token: v.Token, - Proxy: v.Proxy, - }, GithubSecurity{ +func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) { + var sec *GithubSecurity + if v.Token != "" { + sec = &GithubSecurity{ Token: v.Token, } + } + return SkillsGithubConfig{ + token: v.Token, + Proxy: v.Proxy, + }, sec } func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) { @@ -1012,21 +1100,25 @@ func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesC return SkillsRegistriesConfig{ ClawHub: clawHub, - }, &clawHubSecurity + }, clawHubSecurity } -func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, SkillsSecurity) { +func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) { registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig() github, githubSecurity := v.Github.ToSkillsGithubConfig() - return SkillsToolsConfig{ - ToolConfig: v.ToolConfig, - Registries: registries, - Github: github, - MaxConcurrentSearches: v.MaxConcurrentSearches, - SearchCache: v.SearchCache, - }, SkillsSecurity{ - Github: &githubSecurity, + var sec *SkillsSecurity + if githubSecurity != nil || registriesSecurity != nil { + sec = &SkillsSecurity{ + Github: githubSecurity, ClawHub: registriesSecurity, } + } + return SkillsToolsConfig{ + ToolConfig: v.ToolConfig, + Registries: registries, + Github: github, + MaxConcurrentSearches: v.MaxConcurrentSearches, + SearchCache: v.SearchCache, + }, sec } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7d0e3657a..b356d474f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1364,7 +1364,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { "test-model": {APIKeys: []string{"sk-model-key-12345"}}, }, // Channel tokens - Channels: ChannelsSecurity{ + Channels: &ChannelsSecurity{ Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"}, Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"}, Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"}, @@ -1382,7 +1382,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { }, }, // Web tool API keys - Web: WebToolsSecurity{ + Web: &WebToolsSecurity{ Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}}, Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}}, Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}}, @@ -1390,7 +1390,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"}, }, // Skills tokens - Skills: SkillsSecurity{ + Skills: &SkillsSecurity{ Github: &GithubSecurity{Token: "github-token-xyz"}, ClawHub: &ClawHubSecurity{AuthToken: "clawhub-auth-token"}, }, diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 48c03f988..c1d0ea0f6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -539,8 +539,9 @@ func DefaultConfig() *Config { }, security: &SecurityConfig{ ModelList: map[string]ModelSecurityEntry{}, - Channels: ChannelsSecurity{}, - Web: WebToolsSecurity{}, + Channels: &ChannelsSecurity{}, + Web: &WebToolsSecurity{}, + Skills: &SkillsSecurity{}, }, } } diff --git a/pkg/config/security.go b/pkg/config/security.go index c6641f099..816d465c7 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -34,10 +34,10 @@ type SecurityConfig struct { ModelList map[string]ModelSecurityEntry `yaml:"model_list,omitempty"` // Channel tokens/secrets - Channels ChannelsSecurity `yaml:"channels,omitempty"` + Channels *ChannelsSecurity `yaml:"channels,omitempty"` - Web WebToolsSecurity `yaml:"web,omitempty"` - Skills SkillsSecurity `yaml:"skills,omitempty"` + Web *WebToolsSecurity `yaml:"web,omitempty"` + Skills *SkillsSecurity `yaml:"skills,omitempty"` // cache for sensitive values and compiled regex (computed once) sensitiveCache *SensitiveDataCache diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go index 74e765f6b..af08a67db 100644 --- a/pkg/config/security_test.go +++ b/pkg/config/security_test.go @@ -59,12 +59,12 @@ func TestSaveAndLoadSecurityConfig(t *testing.T) { APIKeys: []string{"key1", "key2"}, }, }, - Channels: ChannelsSecurity{ + Channels: &ChannelsSecurity{ Telegram: &TelegramSecurity{ Token: "telegram-token", }, }, - Web: WebToolsSecurity{ + Web: &WebToolsSecurity{ Brave: &BraveSecurity{ APIKeys: []string{"brave-api-key"}, }, diff --git a/pkg/fileutil/file.go b/pkg/fileutil/file.go index 7ca872374..22374ac3d 100644 --- a/pkg/fileutil/file.go +++ b/pkg/fileutil/file.go @@ -117,3 +117,11 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { cleanup = false return nil } + +func CopyFile(src, dst string, perm os.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return WriteFileAtomic(dst, data, perm) +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 802b28526..1e3b5f90a 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" ) // registerModelRoutes binds model list management endpoints to the ServeMux. @@ -158,7 +159,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var mc config.ModelConfig + type custom struct { + config.ModelConfig + APIKey string `json:"api_key"` + } + + var mc custom if err = json.Unmarshal(body, &mc); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return @@ -182,14 +188,18 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { // Preserve the existing API key when the caller omits it (empty string). // This lets the UI update api_base / proxy without clearing the stored secret. - if mc.APIKey() == "" { - mc.SetAPIKey(cfg.ModelList[idx].APIKey()) + if mc.APIKey == "" { + mc.ModelConfig.SetAPIKey(cfg.ModelList[idx].APIKey()) + } else { + mc.ModelConfig.SetAPIKey(mc.APIKey) } if mc.ExtraBody == nil { mc.ExtraBody = cfg.ModelList[idx].ExtraBody } - cfg.ModelList[idx] = &mc + cfg.ModelList[idx] = &mc.ModelConfig + + logger.Debugf("update model config: %#v", mc.ModelConfig) if err := config.SaveConfig(h.configPath, cfg); err != nil { http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) From ce1619051d1e043ae71786e01e752d4aa0263658 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:26:20 +0800 Subject: [PATCH 2/3] fix(chat): avoid full secret exposure for 7-char secrets (#1942) - ensure at least 40% of the characters are masked for secrets of length 4 or more - secrets with length <= 6 now show first and last char with mask - secrets with length <= 12 now show first two and last two chars - longer secrets show 3 prefix and 4 suffix --- web/frontend/src/components/secret-placeholder.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/web/frontend/src/components/secret-placeholder.ts b/web/frontend/src/components/secret-placeholder.ts index c6167d78e..88d4cb311 100644 --- a/web/frontend/src/components/secret-placeholder.ts +++ b/web/frontend/src/components/secret-placeholder.ts @@ -4,13 +4,20 @@ export function maskedSecretPlaceholder(value: unknown, fallback = ""): string { return fallback } - if (secret.length < 7) { + // ensure at least 40% of the characters are masked for secrets of length 4 or more + if (secret.length <= 6) { const first = secret[0] const last = secret[secret.length - 1] return `${first}***${last}` } - const prefix = secret.slice(0, Math.min(3, secret.length)) - const suffix = secret.slice(-Math.min(4, secret.length)) - return `${prefix}***${suffix}` + if (secret.length <= 12) { + const firstTwo = secret.slice(0, 2) + const lastTwo = secret.slice(-2) + return `${firstTwo}****${lastTwo}` + } + + const prefix = secret.slice(0, 3) + const suffix = secret.slice(-4) + return `${prefix}*****${suffix}` } From b23a6b3f54e3ed0f38ab1d4ee563b938251c82f8 Mon Sep 17 00:00:00 2001 From: Hua Audio Date: Tue, 24 Mar 2026 06:33:35 +0100 Subject: [PATCH 3/3] Feat/move weixin login to auth and update docs (#1945) * move weixin to auth and update docs * fix ci test --- README.fr.md | 2 +- README.id.md | 2 +- README.it.md | 2 +- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 2 +- README.vi.md | 2 +- README.zh.md | 2 +- cmd/picoclaw/internal/auth/command.go | 1 + cmd/picoclaw/internal/auth/command_test.go | 1 + cmd/picoclaw/internal/{onboard => auth}/weixin.go | 4 ++-- cmd/picoclaw/internal/onboard/command.go | 5 +---- cmd/picoclaw/internal/onboard/command_test.go | 5 ++--- docs/channels/weixin/README.md | 2 +- docs/channels/weixin/README.zh.md | 2 +- docs/chat-apps.md | 2 +- docs/fr/chat-apps.md | 2 +- docs/ja/chat-apps.md | 2 +- docs/pt-br/chat-apps.md | 2 +- docs/vi/chat-apps.md | 2 +- docs/zh/chat-apps.md | 2 +- 21 files changed, 23 insertions(+), 25 deletions(-) rename cmd/picoclaw/internal/{onboard => auth}/weixin.go (98%) diff --git a/README.fr.md b/README.fr.md index 301456262..a4fa628c9 100644 --- a/README.fr.md +++ b/README.fr.md @@ -524,7 +524,7 @@ Connectez PicoClaw au réseau social des Agents simplement en envoyant un seul m | Commande | Description | | ------------------------- | ---------------------------------------- | | `picoclaw onboard` | Initialiser la config & le workspace | -| `picoclaw onboard weixin` | Connecter un compte WeChat via QR | +| `picoclaw auth weixin` | Connecter un compte WeChat via QR | | `picoclaw agent -m "..."` | Chatter avec l'agent | | `picoclaw agent` | Mode chat interactif | | `picoclaw gateway` | Démarrer le gateway | diff --git a/README.id.md b/README.id.md index 6b7025ffd..6d62dcb9b 100644 --- a/README.id.md +++ b/README.id.md @@ -520,7 +520,7 @@ Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan mel | Perintah | Deskripsi | | -------------------------- | -------------------------------- | | `picoclaw onboard` | Inisialisasi konfigurasi & workspace | -| `picoclaw onboard weixin` | Hubungkan akun WeChat via QR | +| `picoclaw auth weixin` | Hubungkan akun WeChat via QR | | `picoclaw agent -m "..."` | Chat dengan agent | | `picoclaw agent` | Mode chat interaktif | | `picoclaw gateway` | Mulai gateway | diff --git a/README.it.md b/README.it.md index dae541a17..1ed73ee54 100644 --- a/README.it.md +++ b/README.it.md @@ -520,7 +520,7 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol | Comando | Descrizione | | ------------------------- | ---------------------------------- | | `picoclaw onboard` | Inizializza config & workspace | -| `picoclaw onboard weixin` | Connetti account WeChat tramite QR | +| `picoclaw auth weixin` | Connetti account WeChat tramite QR | | `picoclaw agent -m "..."` | Chatta con l'agent | | `picoclaw agent` | Modalità chat interattiva | | `picoclaw gateway` | Avvia il gateway | diff --git a/README.ja.md b/README.ja.md index 3096d4022..9165986ba 100644 --- a/README.ja.md +++ b/README.ja.md @@ -520,7 +520,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ | コマンド | 説明 | | ------------------------- | ------------------------------ | | `picoclaw onboard` | 設定&ワークスペースの初期化 | -| `picoclaw onboard weixin` | WeChat アカウントを QR で接続 | +| `picoclaw auth weixin` | WeChat アカウントを QR で接続 | | `picoclaw agent -m "..."` | Agent とチャット | | `picoclaw agent` | インタラクティブチャットモード | | `picoclaw gateway` | Gateway を起動 | diff --git a/README.md b/README.md index 72d38103c..568c87e59 100644 --- a/README.md +++ b/README.md @@ -523,7 +523,7 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message | Command | Description | | ------------------------- | -------------------------------- | | `picoclaw onboard` | Initialize config & workspace | -| `picoclaw onboard weixin` | Connect WeChat account via QR | +| `picoclaw auth weixin` | Connect WeChat account via QR | | `picoclaw agent -m "..."` | Chat with the agent | | `picoclaw agent` | Interactive chat mode | | `picoclaw gateway` | Start the gateway | diff --git a/README.pt-br.md b/README.pt-br.md index 3c039f190..d4b303e24 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -520,7 +520,7 @@ Conecte o PicoClaw à Rede Social de Agents simplesmente enviando uma única men | Comando | Descrição | | ------------------------- | -------------------------------------- | | `picoclaw onboard` | Inicializar config e workspace | -| `picoclaw onboard weixin` | Conectar conta WeChat via QR | +| `picoclaw auth weixin` | Conectar conta WeChat via QR | | `picoclaw agent -m "..."` | Conversar com o agent | | `picoclaw agent` | Modo de chat interativo | | `picoclaw gateway` | Iniciar o gateway | diff --git a/README.vi.md b/README.vi.md index b63fd4ef7..ceeb02b63 100644 --- a/README.vi.md +++ b/README.vi.md @@ -520,7 +520,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một | Lệnh | Mô tả | | ------------------------- | ---------------------------------------- | | `picoclaw onboard` | Khởi tạo cấu hình & workspace | -| `picoclaw onboard weixin` | Kết nối tài khoản WeChat qua QR | +| `picoclaw auth weixin` | Kết nối tài khoản WeChat qua QR | | `picoclaw agent -m "..."` | Trò chuyện với agent | | `picoclaw agent` | Chế độ trò chuyện tương tác | | `picoclaw gateway` | Khởi động gateway | diff --git a/README.zh.md b/README.zh.md index de96e5164..93abf89d3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -520,7 +520,7 @@ PicoClaw 原生支持 [MCP](https://modelcontextprotocol.io/) — 连接任意 M | 命令 | 说明 | | ------------------------- | ---------------------- | | `picoclaw onboard` | 初始化配置与工作区 | -| `picoclaw onboard weixin` | 扫码连接微信个人号 | +| `picoclaw auth weixin` | 扫码连接微信个人号 | | `picoclaw agent -m "..."` | 与 Agent 对话 | | `picoclaw agent` | 交互式对话模式 | | `picoclaw gateway` | 启动网关 | diff --git a/cmd/picoclaw/internal/auth/command.go b/cmd/picoclaw/internal/auth/command.go index 12a0a3a8c..149095699 100644 --- a/cmd/picoclaw/internal/auth/command.go +++ b/cmd/picoclaw/internal/auth/command.go @@ -16,6 +16,7 @@ func NewAuthCommand() *cobra.Command { newLogoutCommand(), newStatusCommand(), newModelsCommand(), + newWeixinCommand(), ) return cmd diff --git a/cmd/picoclaw/internal/auth/command_test.go b/cmd/picoclaw/internal/auth/command_test.go index 48dc704dd..12f2bc186 100644 --- a/cmd/picoclaw/internal/auth/command_test.go +++ b/cmd/picoclaw/internal/auth/command_test.go @@ -32,6 +32,7 @@ func TestNewAuthCommand(t *testing.T) { "logout", "status", "models", + "weixin", } subcommands := cmd.Commands() diff --git a/cmd/picoclaw/internal/onboard/weixin.go b/cmd/picoclaw/internal/auth/weixin.go similarity index 98% rename from cmd/picoclaw/internal/onboard/weixin.go rename to cmd/picoclaw/internal/auth/weixin.go index 2e1c2ad75..948a81495 100644 --- a/cmd/picoclaw/internal/onboard/weixin.go +++ b/cmd/picoclaw/internal/auth/weixin.go @@ -1,4 +1,4 @@ -package onboard +package auth import ( "context" @@ -27,7 +27,7 @@ to authorize your account. On success, the bot token is saved to the picoclaw config so you can start the gateway immediately. Example: - picoclaw onboard weixin`, + picoclaw auth weixin`, RunE: func(cmd *cobra.Command, _ []string) error { return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second) }, diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index 1f94c6718..4be19b2a5 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -16,7 +16,7 @@ func NewOnboardCommand() *cobra.Command { cmd := &cobra.Command{ Use: "onboard", Aliases: []string{"o"}, - Short: "Initialize picoclaw configuration, workspace, and channel accounts", + Short: "Initialize picoclaw configuration and workspace", // Run without subcommands → original onboard flow Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { @@ -30,8 +30,5 @@ func NewOnboardCommand() *cobra.Command { cmd.Flags().BoolVar(&encrypt, "enc", false, "Enable credential encryption (generates SSH key and prompts for passphrase)") - // Channel onboarding subcommands - cmd.AddCommand(newWeixinCommand()) - return cmd } diff --git a/cmd/picoclaw/internal/onboard/command_test.go b/cmd/picoclaw/internal/onboard/command_test.go index 6b9fb6e95..56936190b 100644 --- a/cmd/picoclaw/internal/onboard/command_test.go +++ b/cmd/picoclaw/internal/onboard/command_test.go @@ -13,7 +13,7 @@ func TestNewOnboardCommand(t *testing.T) { require.NotNil(t, cmd) assert.Equal(t, "onboard", cmd.Use) - assert.Equal(t, "Initialize picoclaw configuration, workspace, and channel accounts", cmd.Short) + assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short) assert.Len(t, cmd.Aliases, 1) assert.True(t, cmd.HasAlias("o")) @@ -28,6 +28,5 @@ func TestNewOnboardCommand(t *testing.T) { encFlag := cmd.Flags().Lookup("enc") require.NotNil(t, encFlag, "expected --enc flag to be registered") assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false") - assert.True(t, cmd.HasSubCommands()) - assert.NotNil(t, cmd.Commands()) + assert.False(t, cmd.HasSubCommands()) } diff --git a/docs/channels/weixin/README.md b/docs/channels/weixin/README.md index 22687fec4..0c51ff3c5 100644 --- a/docs/channels/weixin/README.md +++ b/docs/channels/weixin/README.md @@ -7,7 +7,7 @@ PicoClaw supports connecting to your personal WeChat account using the official The easiest way to set up the Weixin channel is using the interactive onboarding command: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` This command will: diff --git a/docs/channels/weixin/README.zh.md b/docs/channels/weixin/README.zh.md index d5e6f0a49..0f1181878 100644 --- a/docs/channels/weixin/README.zh.md +++ b/docs/channels/weixin/README.zh.md @@ -7,7 +7,7 @@ PicoClaw 支持使用腾讯官方 iLink API 连接您的个人微信账号。 最简单的方法是使用交互式 onboarding 命令进行一键激活: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` 该命令将: diff --git a/docs/chat-apps.md b/docs/chat-apps.md index d300f5544..4a78f465e 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -190,7 +190,7 @@ PicoClaw supports connecting to your personal WeChat account using the official Run the interactive QR login flow: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` Scan the printed QR code with your WeChat mobile app. On success, the token is saved to your config. diff --git a/docs/fr/chat-apps.md b/docs/fr/chat-apps.md index daff951f4..c36e002ff 100644 --- a/docs/fr/chat-apps.md +++ b/docs/fr/chat-apps.md @@ -179,7 +179,7 @@ PicoClaw prend en charge la connexion à votre compte WeChat personnel via l'API Lancez le flux de connexion interactif par QR code : ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` Scannez le QR code affiché avec votre application WeChat mobile. Une fois connecté, le token est sauvegardé dans votre configuration. diff --git a/docs/ja/chat-apps.md b/docs/ja/chat-apps.md index 789c0125f..341dc4aba 100644 --- a/docs/ja/chat-apps.md +++ b/docs/ja/chat-apps.md @@ -184,7 +184,7 @@ PicoClaw は Tencent iLink 公式 API を使用して WeChat 個人アカウン インタラクティブな QR ログインフローを実行します: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` WeChat モバイルアプリで表示された QR コードをスキャンしてください。ログイン成功後、トークンが設定ファイルに保存されます。 diff --git a/docs/pt-br/chat-apps.md b/docs/pt-br/chat-apps.md index 4fa59b1b2..92fda329c 100644 --- a/docs/pt-br/chat-apps.md +++ b/docs/pt-br/chat-apps.md @@ -179,7 +179,7 @@ O PicoClaw suporta conexão com sua conta pessoal do WeChat usando a API oficial Execute o fluxo de login interativo por QR code: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` Escaneie o QR code exibido com seu aplicativo WeChat mobile. Após o login bem-sucedido, o token é salvo na sua configuração. diff --git a/docs/vi/chat-apps.md b/docs/vi/chat-apps.md index d907e5e91..5e2a81ccf 100644 --- a/docs/vi/chat-apps.md +++ b/docs/vi/chat-apps.md @@ -179,7 +179,7 @@ PicoClaw hỗ trợ kết nối với tài khoản WeChat cá nhân của bạn Chạy luồng đăng nhập QR tương tác: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` Quét mã QR được in ra bằng ứng dụng WeChat trên điện thoại. Sau khi đăng nhập thành công, token sẽ được lưu vào cấu hình. diff --git a/docs/zh/chat-apps.md b/docs/zh/chat-apps.md index aeba7d460..4d1451d68 100644 --- a/docs/zh/chat-apps.md +++ b/docs/zh/chat-apps.md @@ -191,7 +191,7 @@ PicoClaw 通过腾讯 iLink 官方 API 支持连接微信个人号。 运行交互式扫码登录流程: ```bash -picoclaw onboard weixin +picoclaw auth weixin ``` 用微信手机端扫描打印出的二维码。登录成功后,token 会自动保存到配置文件。