From b646d3b8fe0950e780e8fa8e68ea7de3877e5e68 Mon Sep 17 00:00:00 2001 From: Cytown Date: Sat, 28 Mar 2026 00:03:34 +0800 Subject: [PATCH] refactor config and security to simplified the structure (#2068) --- cmd/picoclaw/internal/auth/wecom_test.go | 4 +- cmd/picoclaw/internal/model/command_test.go | 186 +-- cmd/picoclaw/internal/skills/command.go | 2 +- cmd/picoclaw/internal/skills/helpers.go | 4 +- pkg/agent/loop.go | 32 +- pkg/agent/loop_test.go | 32 +- pkg/channels/dingtalk/dingtalk.go | 4 +- pkg/channels/discord/discord.go | 2 +- pkg/channels/feishu/feishu_64.go | 8 +- pkg/channels/irc/handler.go | 4 +- pkg/channels/irc/irc.go | 6 +- pkg/channels/line/line.go | 10 +- pkg/channels/manager.go | 16 +- pkg/channels/manager_channel.go | 72 +- pkg/channels/manager_channel_test.go | 4 +- pkg/channels/matrix/matrix.go | 2 +- pkg/channels/onebot/onebot.go | 4 +- pkg/channels/pico/client.go | 4 +- pkg/channels/pico/client_test.go | 4 +- pkg/channels/pico/pico.go | 6 +- pkg/channels/qq/qq.go | 4 +- pkg/channels/slack/slack.go | 8 +- pkg/channels/slack/slack_test.go | 16 +- pkg/channels/telegram/telegram.go | 2 +- pkg/channels/wecom/wecom.go | 4 +- pkg/channels/weixin/state.go | 2 +- pkg/channels/weixin/weixin.go | 2 +- pkg/config/config.go | 1433 ++++------------- pkg/config/config_old.go | 507 +++--- pkg/config/config_test.go | 172 +- pkg/config/defaults.go | 6 - pkg/config/migration_integration_test.go | 20 +- pkg/config/model_config_test.go | 59 +- pkg/config/multikey_test.go | 18 +- pkg/config/security.go | 613 ++++--- pkg/config/security_integration_test.go | 101 +- pkg/config/security_test.go | 319 +++- pkg/credential/credential.go | 23 +- .../sources/openclaw/openclaw_config.go | 24 +- .../sources/openclaw/openclaw_config_test.go | 4 +- pkg/voice/transcriber_test.go | 131 +- web/backend/api/config.go | 18 +- web/backend/api/config_test.go | 10 +- web/backend/api/models_test.go | 6 +- web/backend/api/oauth_test.go | 8 +- web/backend/api/pico.go | 6 +- web/backend/api/pico_test.go | 14 +- web/backend/api/weixin_test.go | 2 +- 48 files changed, 1566 insertions(+), 2372 deletions(-) diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index c2a4624ae..95969d9b3 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -121,7 +121,7 @@ func TestApplyWeComAuthResult(t *testing.T) { assert.True(t, cfg.Channels.WeCom.Enabled) assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID) - assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret()) + assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String()) assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL) } @@ -151,7 +151,7 @@ func TestAuthWeComCmdWithScanner(t *testing.T) { require.NoError(t, err) assert.True(t, cfg.Channels.WeCom.Enabled) assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID) - assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret()) + assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String()) assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL) assert.Contains(t, output.String(), "WeCom connected.") } diff --git a/cmd/picoclaw/internal/model/command_test.go b/cmd/picoclaw/internal/model/command_test.go index 6cbbf0b55..8be29ba95 100644 --- a/cmd/picoclaw/internal/model/command_test.go +++ b/cmd/picoclaw/internal/model/command_test.go @@ -58,24 +58,21 @@ func TestNewModelCommand(t *testing.T) { } func TestShowCurrentModel_WithDefaultModel(t *testing.T) { - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4"}, - {ModelName: "claude-3", Model: "anthropic/claude-3"}, + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "claude-3", + Model: "anthropic/claude-3", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "gpt-4": { - APIKeys: []string{"test"}, - }, - "claude-3": { - APIKeys: []string{"test"}, - }, - }}) + } output := captureStdout(func() { showCurrentModel(cfg) @@ -88,20 +85,16 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) { } func TestShowCurrentModel_NoDefaultModel(t *testing.T) { - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4"}, + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "gpt-4": { - APIKeys: []string{"test"}, - }, - }}) + } output := captureStdout(func() { showCurrentModel(cfg) @@ -124,25 +117,22 @@ func TestListAvailableModels_Empty(t *testing.T) { } func TestListAvailableModels_WithModels(t *testing.T) { - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "gpt-4", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "gpt-4", Model: "openai/gpt-4"}, - {ModelName: "claude-3", Model: "anthropic/claude-3"}, + {ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}}, + { + ModelName: "claude-3", + Model: "anthropic/claude-3", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, {ModelName: "no-key-model", Model: "openai/test"}, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "gpt-4": { - APIKeys: []string{"test"}, - }, - "claude-3": { - APIKeys: []string{"test"}, - }, - }}) + } output := captureStdout(func() { listAvailableModels(cfg) @@ -157,24 +147,25 @@ func TestListAvailableModels_WithModels(t *testing.T) { func TestSetDefaultModel_ValidModel(t *testing.T) { initTest(t) - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "new-model", Model: "openai/new-model"}, - {ModelName: "old-model", Model: "openai/old-model"}, + { + ModelName: "new-model", + Model: "openai/new-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, + { + ModelName: "old-model", + Model: "openai/old-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "new-model": { - APIKeys: []string{"test"}, - }, - "old-model": { - APIKeys: []string{"test"}, - }, - }}) + } output := captureStdout(func() { err := setDefaultModel(configPath, cfg, "new-model") @@ -192,20 +183,20 @@ func TestSetDefaultModel_ValidModel(t *testing.T) { func TestSetDefaultModel_InvalidModel(t *testing.T) { initTest(t) - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "existing-model", Model: "openai/existing"}, + { + ModelName: "existing-model", + Model: "openai/existing", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "existing-model": { - APIKeys: []string{"test"}, - }, - }}) + } assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model")) } @@ -213,24 +204,21 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) { func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) { initTest(t) - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "existing-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "existing-model", Model: "openai/existing"}, + { + ModelName: "existing-model", + Model: "openai/existing", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, {ModelName: "no-key-model", Model: "openai/nokey"}, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "existing-model": { - APIKeys: []string{"test"}, - }, - "no-key-model": { - APIKeys: []string{""}, - }, - }}) + } assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model")) } @@ -239,20 +227,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) { // Use an invalid path to trigger save error invalidPath := "/nonexistent/directory/config.json" - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "new-model", Model: "openai/new-model"}, + { + ModelName: "new-model", + Model: "openai/new-model", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "new-model": { - APIKeys: []string{"test"}, - }, - }}) + } err := setDefaultModel(invalidPath, cfg, "new-model") @@ -284,20 +272,20 @@ func TestModelCommandExecution_Show(t *testing.T) { initTest(t) // Create a test config - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "test-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "test-model", Model: "openai/test"}, + { + ModelName: "test-model", + Model: "openai/test", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "test-model": { - APIKeys: []string{"test"}, - }, - }}) + } err := config.SaveConfig(configPath, cfg) require.NoError(t, err) @@ -315,25 +303,25 @@ func TestModelCommandExecution_Show(t *testing.T) { func TestModelCommandExecution_Set(t *testing.T) { initTest(t) - sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "old-model": { - APIKeys: []string{"test"}, - }, - "new-model": { - APIKeys: []string{"test"}, - }, - }} - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "old-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "old-model", Model: "openai/old"}, - {ModelName: "new-model", Model: "openai/new"}, + { + ModelName: "old-model", + Model: "openai/old", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, + { + ModelName: "new-model", + Model: "openai/new", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(sec) + } err := config.SaveConfig(configPath, cfg) require.NoError(t, err) @@ -357,28 +345,30 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) { } func TestListAvailableModels_MarkerLogic(t *testing.T) { - cfg := (&config.Config{ + cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ ModelName: "middle-model", }, }, ModelList: []*config.ModelConfig{ - {ModelName: "first-model", Model: "openai/first"}, - {ModelName: "middle-model", Model: "openai/middle"}, - {ModelName: "last-model", Model: "openai/last"}, + { + ModelName: "first-model", + Model: "openai/first", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, + { + ModelName: "middle-model", + Model: "openai/middle", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, + { + ModelName: "last-model", + Model: "openai/last", + APIKeys: config.SecureStrings{config.NewSecureString("test")}, + }, }, - }).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "first-model": { - APIKeys: []string{"test"}, - }, - "middle-model": { - APIKeys: []string{"test"}, - }, - "last-model": { - APIKeys: []string{"test"}, - }, - }}) + } output := captureStdout(func() { listAvailableModels(cfg) diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index 4f64ef3f9..e8b884977 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command { d.workspace = cfg.WorkspacePath() installer, err := skills.NewSkillInstaller( d.workspace, - cfg.Tools.Skills.Github.Token(), + cfg.Tools.Skills.Github.Token.String(), cfg.Tools.Skills.Github.Proxy, ) if err != nil { diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a246f7da5..eec2dbb94 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -70,7 +70,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er ClawHub: skills.ClawHubConfig{ Enabled: clawHubConfig.Enabled, BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken(), + AuthToken: clawHubConfig.AuthToken.String(), SearchPath: clawHubConfig.SearchPath, SkillsPath: clawHubConfig.SkillsPath, DownloadPath: clawHubConfig.DownloadPath, @@ -243,7 +243,7 @@ func skillsSearchCmd(query string) { ClawHub: skills.ClawHubConfig{ Enabled: clawHubConfig.Enabled, BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken(), + AuthToken: clawHubConfig.AuthToken.String(), SearchPath: clawHubConfig.SearchPath, SkillsPath: clawHubConfig.SkillsPath, DownloadPath: clawHubConfig.DownloadPath, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 81b979490..439f4d24c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -165,33 +165,27 @@ func registerSharedTools( if cfg.Tools.IsToolEnabled("web") { searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()), - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKeys: config.MergeAPIKeys( - cfg.Tools.Web.Tavily.APIKey(), - cfg.Tools.Web.Tavily.APIKeys(), - ), - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKeys: config.MergeAPIKeys( - cfg.Tools.Web.Perplexity.APIKey(), - cfg.Tools.Web.Perplexity.APIKeys(), - ), + BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(), + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey(), + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, @@ -263,7 +257,7 @@ func registerSharedTools( ClawHub: skills.ClawHubConfig{ Enabled: clawHubConfig.Enabled, BaseURL: clawHubConfig.BaseURL, - AuthToken: clawHubConfig.AuthToken(), + AuthToken: clawHubConfig.AuthToken.String(), SearchPath: clawHubConfig.SearchPath, SkillsPath: clawHubConfig.SkillsPath, DownloadPath: clawHubConfig.DownloadPath, diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2366b1277..a3fae5744 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1467,24 +1467,16 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { ModelName: "local", Model: "openai/local-model", APIBase: "https://local.example.invalid/v1", + APIKeys: config.SimpleSecureStrings("test-key"), }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", APIBase: "https://openrouter.ai/api/v1", + APIKeys: config.SimpleSecureStrings("test-key"), }, }, } - cfg.WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "local": { - APIKeys: []string{"test-key"}, - }, - "deepseek": { - APIKeys: []string{"test-key"}, - }, - }, - }) msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} @@ -1546,16 +1538,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) { ModelName: "local", Model: "openai/local-model", APIBase: "https://local.example.invalid/v1", + APIKeys: config.SimpleSecureStrings("test-key"), }, }, } - cfg.WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "local": { - APIKeys: []string{"test-key"}, - }, - }, - }) msgBus := bus.NewMessageBus() provider := &countingMockProvider{response: "LLM reply"} @@ -1627,24 +1613,16 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t ModelName: "local", Model: "openai/Qwen3.5-35B-A3B", APIBase: localServer.URL, + APIKeys: config.SimpleSecureStrings("local-key"), }, { ModelName: "deepseek", Model: "openrouter/deepseek/deepseek-v3.2", APIBase: remoteServer.URL, + APIKeys: config.SimpleSecureStrings("remote-key"), }, }, } - cfg.WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "local": { - APIKeys: []string{"local-key"}, - }, - "deepseek": { - APIKeys: []string{"remote-key"}, - }, - }, - }) msgBus := bus.NewMessageBus() provider, _, err := providers.CreateProvider(cfg) diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 7ac2c073f..273e2b020 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -36,7 +36,7 @@ type DingTalkChannel struct { // NewDingTalkChannel creates a new DingTalk channel instance func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { - if cfg.ClientID == "" || cfg.ClientSecret() == "" { + if cfg.ClientID == "" || cfg.ClientSecret.String() == "" { return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } @@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( BaseChannel: base, config: cfg, clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret(), + clientSecret: cfg.ClientSecret.String(), }, nil } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 2385544a6..cc0ef4ffe 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC discordgo.LogDebug: logger.DEBUG, }).Log - session, err := discordgo.New("Bot " + cfg.Token()) + session, err := discordgo.New("Bot " + cfg.Token.String()) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 76df988ad..5c57cfb02 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan BaseChannel: base, config: cfg, tokenCache: tc, - client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...), + client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } ch.SetOwner(ch) return ch, nil } func (c *FeishuChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret() == "" { + if c.config.AppID == "" || c.config.AppSecret.String() == "" { return fmt.Errorf("feishu app_id or app_secret is empty") } @@ -81,7 +81,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { }) } - dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()). + dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken.String(), c.config.EncryptKey.String()). OnP2MessageReceiveV1(c.handleMessageReceive) runCtx, cancel := context.WithCancel(ctx) @@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { } c.wsClient = larkws.NewClient( c.config.AppID, - c.config.AppSecret(), + c.config.AppSecret.String(), larkws.WithEventHandler(dispatcher), larkws.WithDomain(domain), ) diff --git a/pkg/channels/irc/handler.go b/pkg/channels/irc/handler.go index 3fe9548f4..b92359da4 100644 --- a/pkg/channels/irc/handler.go +++ b/pkg/channels/irc/handler.go @@ -17,8 +17,8 @@ import ( // onConnect is called after a successful connection (and on reconnect). func (c *IRCChannel) onConnect(conn *ircevent.Connection) { // NickServ auth (only if SASL is not configured) - if c.config.NickServPassword() != "" && c.config.SASLUser == "" { - conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword()) + if c.config.NickServPassword.String() != "" && c.config.SASLUser == "" { + conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword.String()) } // Join configured channels diff --git a/pkg/channels/irc/irc.go b/pkg/channels/irc/irc.go index 289ce2c9b..3a4f213ca 100644 --- a/pkg/channels/irc/irc.go +++ b/pkg/channels/irc/irc.go @@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error { Nick: c.config.Nick, User: user, RealName: realName, - Password: c.config.Password(), + Password: c.config.Password.String(), UseTLS: c.config.TLS, RequestCaps: caps, QuitMessage: "Goodbye", @@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error { } // SASL auth (takes priority over NickServ) - if c.config.SASLUser != "" && c.config.SASLPassword() != "" { + if c.config.SASLUser != "" && c.config.SASLPassword.String() != "" { conn.SASLLogin = c.config.SASLUser - conn.SASLPassword = c.config.SASLPassword() + conn.SASLPassword = c.config.SASLPassword.String() } // Register event handlers diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 4eaadae70..867ab24ee 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -62,7 +62,7 @@ type LINEChannel struct { // NewLINEChannel creates a new LINE channel instance. func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { - if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" { + if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" { return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } @@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error { if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken()) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) resp, err := c.infoClient.Do(req) if err != nil { @@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool { return false } - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret())) + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String())) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) @@ -655,7 +655,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken()) + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String()) resp, err := c.apiClient.Do(req) if err != nil { @@ -680,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "line", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.ChannelAccessToken(), + "Authorization": "Bearer " + c.config.ChannelAccessToken.String(), }, }) } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7bcb933ce..0486d2c5f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -353,7 +353,7 @@ func (m *Manager) initChannel(name, displayName string) { func (m *Manager) initChannels(channels *config.ChannelsConfig) error { logger.InfoC("channels", "Initializing channel manager") - if channels.Telegram.Enabled && channels.Telegram.Token() != "" { + if channels.Telegram.Enabled && channels.Telegram.Token.String() != "" { m.initChannel("telegram", "Telegram") } @@ -370,7 +370,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("feishu", "Feishu") } - if channels.Discord.Enabled && channels.Discord.Token() != "" { + if channels.Discord.Enabled && channels.Discord.Token.String() != "" { m.initChannel("discord", "Discord") } @@ -386,18 +386,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("dingtalk", "DingTalk") } - if channels.Slack.Enabled && channels.Slack.BotToken() != "" { + if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" { m.initChannel("slack", "Slack") } if channels.Matrix.Enabled && m.config.Channels.Matrix.Homeserver != "" && m.config.Channels.Matrix.UserID != "" && - m.config.Channels.Matrix.AccessToken() != "" { + m.config.Channels.Matrix.AccessToken.String() != "" { m.initChannel("matrix", "Matrix") } - if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" { + if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" { m.initChannel("line", "LINE") } @@ -405,15 +405,15 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("onebot", "OneBot") } - if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret() != "" { + if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret.String() != "" { m.initChannel("wecom", "WeCom") } - if channels.Weixin.Enabled && channels.Weixin.Token() != "" { + if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" { m.initChannel("weixin", "Weixin") } - if channels.Pico.Enabled && channels.Pico.Token() != "" { + if channels.Pico.Enabled && channels.Pico.Token.String() != "" { m.initChannel("pico", "Pico") } diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index 163218b75..b1c8c25e0 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -33,35 +33,35 @@ func toChannelHashes(cfg *config.Config) map[string]string { func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { switch key { case "pico": - value["token"] = ch.Pico.Token() + value["token"] = ch.Pico.Token.String() case "telegram": - value["token"] = ch.Telegram.Token() + value["token"] = ch.Telegram.Token.String() case "discord": - value["token"] = ch.Discord.Token() + value["token"] = ch.Discord.Token.String() case "slack": - value["bot_token"] = ch.Slack.BotToken() - value["app_token"] = ch.Slack.AppToken() + value["bot_token"] = ch.Slack.BotToken.String() + value["app_token"] = ch.Slack.AppToken.String() case "matrix": - value["token"] = ch.Matrix.AccessToken() + value["token"] = ch.Matrix.AccessToken.String() case "onebot": - value["token"] = ch.OneBot.AccessToken() + value["token"] = ch.OneBot.AccessToken.String() case "line": - value["token"] = ch.LINE.ChannelAccessToken() - value["secret"] = ch.LINE.ChannelSecret() + value["token"] = ch.LINE.ChannelAccessToken.String() + value["secret"] = ch.LINE.ChannelSecret.String() case "wecom": - value["secret"] = ch.WeCom.Secret() + value["secret"] = ch.WeCom.Secret.String() case "dingtalk": - value["secret"] = ch.QQ.AppSecret() + value["secret"] = ch.DingTalk.ClientSecret.String() case "qq": - value["secret"] = ch.DingTalk.ClientSecret() + value["secret"] = ch.QQ.AppSecret.String() case "irc": - value["password"] = ch.IRC.Password() - value["serv_password"] = ch.IRC.NickServPassword() - value["sasl_password"] = ch.IRC.SASLPassword() + value["password"] = ch.IRC.Password.String() + value["serv_password"] = ch.IRC.NickServPassword.String() + value["sasl_password"] = ch.IRC.SASLPassword.String() case "feishu": - value["app_secret"] = ch.Feishu.AppSecret() - value["encrypt_key"] = ch.Feishu.EncryptKey() - value["verification_token"] = ch.Feishu.VerificationToken() + value["app_secret"] = ch.Feishu.AppSecret.String() + value["encrypt_key"] = ch.Feishu.EncryptKey.String() + value["verification_token"] = ch.Feishu.VerificationToken.String() } } @@ -125,45 +125,45 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig, func updateKeys(newcfg, old *config.ChannelsConfig) { if newcfg.Pico.Enabled { - newcfg.Pico.SetToken(old.Pico.Token()) + newcfg.Pico.Token = old.Pico.Token } if newcfg.Telegram.Enabled { - newcfg.Telegram.SetToken(old.Telegram.Token()) + newcfg.Telegram.Token = old.Telegram.Token } if newcfg.Discord.Enabled { - newcfg.Discord.SetToken(old.Discord.Token()) + newcfg.Discord.Token = old.Discord.Token } if newcfg.Slack.Enabled { - newcfg.Slack.SetBotToken(old.Slack.BotToken()) - newcfg.Slack.SetAppToken(old.Slack.AppToken()) + newcfg.Slack.BotToken = old.Slack.BotToken + newcfg.Slack.AppToken = old.Slack.AppToken } if newcfg.Matrix.Enabled { - newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken()) + newcfg.Matrix.AccessToken = old.Matrix.AccessToken } if newcfg.OneBot.Enabled { - newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken()) + newcfg.OneBot.AccessToken = old.OneBot.AccessToken } if newcfg.LINE.Enabled { - newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken()) - newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret()) + newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken + newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret } if newcfg.WeCom.Enabled { - newcfg.WeCom.SetSecret(old.WeCom.Secret()) + newcfg.WeCom.Secret = old.WeCom.Secret } if newcfg.DingTalk.Enabled { - newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret()) + newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret } if newcfg.QQ.Enabled { - newcfg.QQ.SetAppSecret(old.QQ.AppSecret()) + newcfg.QQ.AppSecret = old.QQ.AppSecret } if newcfg.IRC.Enabled { - newcfg.IRC.SetPassword(old.IRC.Password()) - newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword()) - newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword()) + newcfg.IRC.Password = old.IRC.Password + newcfg.IRC.NickServPassword = old.IRC.NickServPassword + newcfg.IRC.SASLPassword = old.IRC.SASLPassword } if newcfg.Feishu.Enabled { - newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret()) - newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey()) - newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken()) + newcfg.Feishu.AppSecret = old.Feishu.AppSecret + newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey + newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken } } diff --git a/pkg/channels/manager_channel_test.go b/pkg/channels/manager_channel_test.go index e17dcf17d..3de1e2b3f 100644 --- a/pkg/channels/manager_channel_test.go +++ b/pkg/channels/manager_channel_test.go @@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) { cc, err := toChannelConfig(cfg3, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "114314", cc.Telegram.Token()) + assert.Equal(t, "114314", cc.Telegram.Token.String()) assert.Equal(t, true, cc.Telegram.Enabled) cc, err = toChannelConfig(cfg2, added) assert.NoError(t, err) logger.Debugf("cc: %#v", cc.Telegram) - assert.Equal(t, "", cc.Telegram.Token()) + assert.Equal(t, "", cc.Telegram.Token.String()) assert.Equal(t, false, cc.Telegram.Enabled) } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index f6370fa20..09b4eaa76 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -200,7 +200,7 @@ func NewMatrixChannel( ) (*MatrixChannel, error) { homeserver := strings.TrimSpace(cfg.Homeserver) userID := strings.TrimSpace(cfg.UserID) - accessToken := strings.TrimSpace(cfg.AccessToken()) + accessToken := strings.TrimSpace(cfg.AccessToken.String()) if homeserver == "" { return nil, fmt.Errorf("matrix homeserver is required") } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 048be48eb..e5f8b8fd1 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error { dialer.HandshakeTimeout = 10 * time.Second header := make(map[string][]string) - if c.config.AccessToken() != "" { - header["Authorization"] = []string{"Bearer " + c.config.AccessToken()} + if c.config.AccessToken.String() != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken.String()} } conn, resp, err := dialer.Dial(c.config.WSUrl, header) diff --git a/pkg/channels/pico/client.go b/pkg/channels/pico/client.go index 2c335050d..4fdcbbf39 100644 --- a/pkg/channels/pico/client.go +++ b/pkg/channels/pico/client.go @@ -81,8 +81,8 @@ func (c *PicoClientChannel) Stop(ctx context.Context) error { func (c *PicoClientChannel) dial() error { header := http.Header{} - if c.config.Token != "" { - header.Set("Authorization", "Bearer "+c.config.Token) + if c.config.Token.String() != "" { + header.Set("Authorization", "Bearer "+c.config.Token.String()) } ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header) diff --git a/pkg/channels/pico/client_test.go b/pkg/channels/pico/client_test.go index 118c9abea..7f2719e7d 100644 --- a/pkg/channels/pico/client_test.go +++ b/pkg/channels/pico/client_test.go @@ -106,7 +106,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) { mb := bus.NewMessageBus() ch, err := NewPicoClientChannel(config.PicoClientConfig{ URL: wsURL(srv.URL), - Token: "test-token", + Token: *config.NewSecureString("test-token"), SessionID: "sess-1", PingInterval: 60, ReadTimeout: 10, @@ -139,7 +139,7 @@ func TestClientChannel_AuthFailure(t *testing.T) { ch, err := NewPicoClientChannel(config.PicoClientConfig{ URL: wsURL(srv.URL), - Token: "wrong-token", + Token: *config.NewSecureString("wrong-token"), }, bus.NewMessageBus()) if err != nil { t.Fatal(err) diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 1aa1941cf..0e2bea67c 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -65,7 +65,7 @@ type PicoChannel struct { // NewPicoChannel creates a new Pico Protocol channel. func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { - if cfg.Token() == "" { + if cfg.Token.String() == "" { return nil, fmt.Errorf("pico token is required") } @@ -381,7 +381,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { // 2. Sec-WebSocket-Protocol "token." (for browsers that can't set headers) // 3. Query parameter "token" (only when AllowTokenQuery is on) func (c *PicoChannel) authenticate(r *http.Request) bool { - token := c.config.Token() + token := c.config.Token.String() if token == "" { return false } @@ -412,7 +412,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { // matchedSubprotocol returns the "token." subprotocol that matches // the configured token, or "" if none do. func (c *PicoChannel) matchedSubprotocol(r *http.Request) string { - token := c.config.Token() + token := c.config.Token.String() for _, proto := range websocket.Subprotocols(r) { if after, ok := strings.CutPrefix(proto, "token."); ok && after == token { return proto diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 4ea71f6df..dfea85ba3 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, } func (c *QQChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret() == "" { + if c.config.AppID == "" || c.config.AppSecret.String() == "" { return fmt.Errorf("QQ app_id and app_secret not configured") } @@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, - AppSecret: c.config.AppSecret(), + AppSecret: c.config.AppSecret.String(), } c.tokenSource = token.NewQQBotTokenSource(credentials) diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index f03283ea4..acd857a06 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -37,13 +37,13 @@ type slackMessageRef struct { } func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { - if cfg.BotToken() == "" || cfg.AppToken() == "" { + if cfg.BotToken.String() == "" || cfg.AppToken.String() == "" { return nil, fmt.Errorf("slack bot_token and app_token are required") } api := slack.New( - cfg.BotToken(), - slack.OptionAppLevelToken(cfg.AppToken()), + cfg.BotToken.String(), + slack.OptionAppLevelToken(cfg.AppToken.String()), ) socketClient := socketmode.New(api) @@ -516,7 +516,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ LoggerPrefix: "slack", ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.BotToken(), + "Authorization": "Bearer " + c.config.BotToken.String(), }, }) } diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go index 23a7ee5c4..d1980a7c9 100644 --- a/pkg/channels/slack/slack_test.go +++ b/pkg/channels/slack/slack_test.go @@ -103,7 +103,7 @@ func TestNewSlackChannel(t *testing.T) { t.Run("missing bot token", func(t *testing.T) { cfg := config.SlackConfig{} - cfg.SetAppToken("xapp-test") + cfg.AppToken = *config.NewSecureString("xapp-test") _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") @@ -112,7 +112,7 @@ func TestNewSlackChannel(t *testing.T) { t.Run("missing app token", func(t *testing.T) { cfg := config.SlackConfig{} - cfg.SetBotToken("xoxb-test") + cfg.BotToken = *config.NewSecureString("xoxb-test") _, err := NewSlackChannel(cfg, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") @@ -123,8 +123,8 @@ func TestNewSlackChannel(t *testing.T) { cfg := config.SlackConfig{ AllowFrom: []string{"U123"}, } - cfg.SetBotToken("xoxb-test") - cfg.SetAppToken("xapp-test") + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") ch, err := NewSlackChannel(cfg, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -145,8 +145,8 @@ func TestSlackChannelIsAllowed(t *testing.T) { cfg := config.SlackConfig{ AllowFrom: []string{}, } - cfg.SetBotToken("xoxb-test") - cfg.SetAppToken("xapp-test") + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") @@ -157,8 +157,8 @@ func TestSlackChannelIsAllowed(t *testing.T) { cfg := config.SlackConfig{ AllowFrom: []string{"U_ALLOWED"}, } - cfg.SetBotToken("xoxb-test") - cfg.SetAppToken("xapp-test") + cfg.BotToken = *config.NewSecureString("xoxb-test") + cfg.AppToken = *config.NewSecureString("xapp-test") ch, _ := NewSlackChannel(cfg, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 5adb40a7e..f64a8f79b 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -83,7 +83,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann } opts = append(opts, telego.WithLogger(logger.NewLogger("telego"))) - bot, err := telego.NewBot(telegramCfg.Token(), opts...) + bot, err := telego.NewBot(telegramCfg.Token.String(), opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } diff --git a/pkg/channels/wecom/wecom.go b/pkg/channels/wecom/wecom.go index 26e971921..6096b7db3 100644 --- a/pkg/channels/wecom/wecom.go +++ b/pkg/channels/wecom/wecom.go @@ -109,7 +109,7 @@ func (s *recentMessageSet) Mark(id string) bool { } func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) { - if cfg.BotID == "" || cfg.Secret() == "" { + if cfg.BotID == "" || cfg.Secret.String() == "" { return nil, fmt.Errorf("wecom bot_id and secret are required") } if cfg.WebSocketURL == "" { @@ -356,7 +356,7 @@ func (c *WeComChannel) runConnection() error { Headers: wecomHeaders{ReqID: randomID(10)}, Body: map[string]string{ "bot_id": c.config.BotID, - "secret": c.config.Secret(), + "secret": c.config.Secret.String(), }, }, wecomCommandTimeout); writeErr != nil { return writeErr diff --git a/pkg/channels/weixin/state.go b/pkg/channels/weixin/state.go index 9672e614d..2d1b9f4a6 100644 --- a/pkg/channels/weixin/state.go +++ b/pkg/channels/weixin/state.go @@ -46,7 +46,7 @@ func picoclawHomeDir() string { func buildWeixinSyncBufPath(cfg config.WeixinConfig) string { key := "default" - token := strings.TrimSpace(cfg.Token()) + token := strings.TrimSpace(cfg.Token.String()) if token != "" { sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token)) key = hex.EncodeToString(sum[:8]) diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index b9e821ef1..fdafb02c2 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -42,7 +42,7 @@ func init() { // NewWeixinChannel creates a new WeixinChannel from config. func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) { - api, err := NewApiClient(cfg.BaseURL, cfg.Token(), cfg.Proxy) + api, err := NewApiClient(cfg.BaseURL, cfg.Token.String(), cfg.Proxy) if err != nil { return nil, fmt.Errorf("weixin: failed to create API client: %w", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d2ff000a8..533f45a44 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,6 +2,7 @@ package config import ( "encoding/json" + "errors" "fmt" "math/rand" "os" @@ -12,7 +13,6 @@ import ( "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg" - "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -106,36 +106,23 @@ const CurrentVersion = 1 // Config is the current config structure with version support type Config struct { - Version int `json:"version"` // Config schema version for migration - Agents AgentsConfig `json:"agents"` - Bindings []AgentBinding `json:"bindings,omitempty"` - Session SessionConfig `json:"session,omitempty"` - Channels ChannelsConfig `json:"channels"` - ModelList []*ModelConfig `json:"model_list"` // New model-centric provider configuration - Gateway GatewayConfig `json:"gateway"` - Hooks HooksConfig `json:"hooks,omitempty"` - Tools ToolsConfig `json:"tools"` - Heartbeat HeartbeatConfig `json:"heartbeat"` - Devices DevicesConfig `json:"devices"` - Voice VoiceConfig `json:"voice"` + Version int `json:"version" yaml:"-"` // Config schema version for migration + Agents AgentsConfig `json:"agents" yaml:"-"` + Bindings []AgentBinding `json:"bindings,omitempty" yaml:"-"` + Session SessionConfig `json:"session,omitempty" yaml:"-"` + Channels ChannelsConfig `json:"channels" yaml:"channels"` + ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration + Gateway GatewayConfig `json:"gateway" yaml:"-"` + Hooks HooksConfig `json:"hooks,omitempty" yaml:"-"` + Tools ToolsConfig `json:"tools" yaml:",inline"` + Heartbeat HeartbeatConfig `json:"heartbeat" yaml:"-"` + Devices DevicesConfig `json:"devices" yaml:"-"` + Voice VoiceConfig `json:"voice" yaml:"-"` // BuildInfo contains build-time version information - BuildInfo BuildInfo `json:"build_info,omitempty"` + BuildInfo BuildInfo `json:"build_info,omitempty" yaml:"-"` - security *SecurityConfig -} - -func (c *Config) WithSecurity(sec *SecurityConfig) *Config { - if sec == nil { - c.security = sec - return c - } - sec = normalizeSecurityConfig(sec) - err := applySecurityConfig(c, sec) - if err != nil { - return nil - } - c.security = sec - return c + // cache for sensitive values and compiled regex (computed once) + sensitiveCache *SensitiveDataCache } // FilterSensitiveData filters sensitive values from content before sending to LLM. @@ -143,9 +130,6 @@ func (c *Config) WithSecurity(sec *SecurityConfig) *Config { // Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). // Short content (below FilterMinLength) is returned unchanged for performance. func (c *Config) FilterSensitiveData(content string) string { - if c.security == nil || content == "" { - return content - } // Check if filtering is enabled (default: true) if !c.Tools.IsFilterSensitiveDataEnabled() { return content @@ -154,7 +138,7 @@ func (c *Config) FilterSensitiveData(content string) string { if len(content) < c.Tools.GetFilterMinLength() { return content } - return c.security.SensitiveDataReplacer().Replace(content) + return c.SensitiveDataReplacer().Replace(content) } type HooksConfig struct { @@ -374,22 +358,22 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp"` - Telegram TelegramConfig `json:"telegram"` - Feishu FeishuConfig `json:"feishu"` - Discord DiscordConfig `json:"discord"` - MaixCam MaixCamConfig `json:"maixcam"` - QQ QQConfig `json:"qq"` - DingTalk DingTalkConfig `json:"dingtalk"` - Slack SlackConfig `json:"slack"` - Matrix MatrixConfig `json:"matrix"` - LINE LINEConfig `json:"line"` - OneBot OneBotConfig `json:"onebot"` - WeCom WeComConfig `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` - Weixin WeixinConfig `json:"weixin"` - Pico PicoConfig `json:"pico"` - PicoClient PicoClientConfig `json:"pico_client"` - IRC IRCConfig `json:"irc"` + WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` + Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` + Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` + Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` + MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` + QQ QQConfig `json:"qq" yaml:"qq,omitempty"` + DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` + Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` + Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` + LINE LINEConfig `json:"line" yaml:"line,omitempty"` + OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` + WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` + Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` + Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` + PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` + IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -428,110 +412,56 @@ type StreamingConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - token string - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - Streaming StreamingConfig `json:"streaming,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` - UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` - secDirty bool + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` + UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } -// Token returns the Telegram bot token -func (c *TelegramConfig) Token() string { - return c.token -} - -// SetToken sets the Telegram bot token func (c *TelegramConfig) SetToken(token string) { - c.token = token - c.secDirty = true + c.Token = *NewSecureString(token) } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - appSecret string - encryptKey string - verificationToken string - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` - RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` - IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` - secDirty bool -} - -// AppSecret returns the Feishu app secret -func (c *FeishuConfig) AppSecret() string { - return c.appSecret -} - -// SetAppSecret sets the Feishu app secret -func (c *FeishuConfig) SetAppSecret(secret string) { - c.appSecret = secret - c.secDirty = true -} - -// EncryptKey returns the Feishu encrypt key -func (c *FeishuConfig) EncryptKey() string { - return c.encryptKey -} - -// SetEncryptKey sets the Feishu encrypt key -func (c *FeishuConfig) SetEncryptKey(key string) { - c.encryptKey = key - c.secDirty = true -} - -// VerificationToken returns the Feishu verification token -func (c *FeishuConfig) VerificationToken() string { - return c.verificationToken -} - -// SetVerificationToken sets the Feishu verification token -func (c *FeishuConfig) SetVerificationToken(token string) { - c.verificationToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey SecureString `json:"encrypt_key,omitzero" yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken SecureString `json:"verification_token,omitzero" yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` + RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` + IsLark bool `json:"is_lark" yaml:"-" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - token string - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` - secDirty bool -} - -// Token returns the Discord bot token -func (c *DiscordConfig) Token() string { - return c.token -} - -// SetToken sets the Discord bot token -func (c *DiscordConfig) SetToken(token string) { - c.token = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + MentionOnly bool `json:"mention_only" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } type MaixCamConfig struct { @@ -543,172 +473,78 @@ type MaixCamConfig struct { } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - appSecret string - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` - MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` - SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` - secDirty bool -} - -// AppSecret returns the QQ app secret -func (c *QQConfig) AppSecret() string { - return c.appSecret -} - -// SetAppSecret sets the QQ app secret -func (c *QQConfig) SetAppSecret(secret string) { - c.appSecret = secret - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret SecureString `json:"app_secret,omitzero" yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + MaxMessageLength int `json:"max_message_length" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` + SendMarkdown bool `json:"send_markdown" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - clientSecret string - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` - secDirty bool -} - -// ClientSecret returns the DingTalk client secret -func (c *DingTalkConfig) ClientSecret() string { - return c.clientSecret -} - -// SetClientSecret sets the DingTalk client secret -func (c *DingTalkConfig) SetClientSecret(secret string) { - c.clientSecret = secret - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret SecureString `json:"client_secret,omitzero" yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - botToken string - appToken string - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` - secDirty bool -} - -// BotToken returns the Slack bot token -func (c *SlackConfig) BotToken() string { - return c.botToken -} - -// SetBotToken sets the Slack bot token -func (c *SlackConfig) SetBotToken(token string) { - c.botToken = token - c.secDirty = true -} - -// AppToken returns the Slack app token -func (c *SlackConfig) AppToken() string { - return c.appToken -} - -// SetAppToken sets the Slack app token -func (c *SlackConfig) SetAppToken(token string) { - c.appToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken SecureString `json:"bot_token,omitzero" yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken SecureString `json:"app_token,omitzero" yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - accessToken string - DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` - JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` - MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` - secDirty bool - CryptoDatabasePath string `json:"crypto_database_path,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_DATABASE_PATH"` - CryptoPassphrase string `json:"crypto_passphrase,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_PASSPHRASE"` -} - -// AccessToken returns the Matrix access token -func (c *MatrixConfig) AccessToken() string { - return c.accessToken -} - -// SetAccessToken sets the Matrix access token -func (c *MatrixConfig) SetAccessToken(token string) { - c.accessToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` + Homeserver string `json:"homeserver" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" yaml:"-" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + DeviceID string `json:"device_id,omitempty" yaml:"-"` + JoinOnInvite bool `json:"join_on_invite" yaml:"-"` + MessageFormat string `json:"message_format,omitempty" yaml:"-"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` + CryptoDatabasePath string `json:"crypto_database_path,omitempty" yaml:"-"` + CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - channelSecret string - channelAccessToken string - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` - secDirty bool -} - -// ChannelSecret returns the LINE channel secret -func (c *LINEConfig) ChannelSecret() string { - return c.channelSecret -} - -// SetChannelSecret sets the LINE channel secret -func (c *LINEConfig) SetChannelSecret(secret string) { - c.channelSecret = secret - c.secDirty = true -} - -// ChannelAccessToken returns the LINE channel access token -func (c *LINEConfig) ChannelAccessToken() string { - return c.channelAccessToken -} - -// SetChannelAccessToken sets the LINE channel access token -func (c *LINEConfig) SetChannelAccessToken(token string) { - c.channelAccessToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - accessToken string - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` - GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` - secDirty bool -} - -// AccessToken returns the OneBot access token -func (c *OneBotConfig) AccessToken() string { - return c.accessToken -} - -// SetAccessToken sets the OneBot access token -func (c *OneBotConfig) SetAccessToken(token string) { - c.accessToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken SecureString `json:"access_token,omitzero" yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` } type WeComGroupConfig struct { @@ -716,132 +552,80 @@ type WeComGroupConfig struct { } type WeComConfig struct { - Enabled bool `json:"enabled" env:"ENABLED"` - BotID string `json:"bot_id" env:"BOT_ID"` - secret string - WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"` - SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"` - secDirty bool + Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` + BotID string `json:"bot_id" yaml:"-" env:"BOT_ID"` + Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"SECRET"` + WebSocketURL string `json:"websocket_url,omitempty" yaml:"-" env:"WEBSOCKET_URL"` + SendThinkingMessage bool `json:"send_thinking_message" yaml:"-" env:"SEND_THINKING_MESSAGE"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"REASONING_CHANNEL_ID"` } -// Secret returns the WeCom bot secret. -func (c *WeComConfig) Secret() string { - return c.secret -} - -// SetSecret sets the WeCom bot secret. func (c *WeComConfig) SetSecret(secret string) { - c.secret = secret - c.secDirty = true + c.Secret = *NewSecureString(secret) } type WeixinConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` - token string - AccountID string `json:"account_id,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` - CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` - secDirty bool + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` + AccountID string `json:"account_id,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ACCOUNT_ID"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` + CDNBaseURL string `json:"cdn_base_url" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` + Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` } -func (c *WeixinConfig) Token() string { - return c.token -} - -func (c *WeixinConfig) SetToken(token string) *WeixinConfig { - c.token = token - c.secDirty = true - return c +// SetToken sets the Weixin token and marks it as dirty for security saving +func (c *WeixinConfig) SetToken(token string) { + c.Token = *NewSecureString(token) } type PicoConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` - token string - AllowTokenQuery bool `json:"allow_token_query,omitempty"` - AllowOrigins []string `json:"allow_origins,omitempty"` - PingInterval int `json:"ping_interval,omitempty"` - ReadTimeout int `json:"read_timeout,omitempty"` - WriteTimeout int `json:"write_timeout,omitempty"` - MaxConnections int `json:"max_connections,omitempty"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - secDirty bool + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + AllowTokenQuery bool `json:"allow_token_query,omitempty" yaml:"-"` + AllowOrigins []string `json:"allow_origins,omitempty" yaml:"-"` + PingInterval int `json:"ping_interval,omitempty" yaml:"-"` + ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` + WriteTimeout int `json:"write_timeout,omitempty" yaml:"-"` + MaxConnections int `json:"max_connections,omitempty" yaml:"-"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty" yaml:"-"` } -// Token returns the Pico channel token -func (c *PicoConfig) Token() string { - return c.token -} - -// SetToken sets the Pico channel token +// SetToken sets the Pico token and marks it as dirty for security saving func (c *PicoConfig) SetToken(token string) { - c.token = token - c.secDirty = true + c.Token = *NewSecureString(token) } type PicoClientConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"` - URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` - SessionID string `json:"session_id,omitempty"` - PingInterval int `json:"ping_interval,omitempty"` - ReadTimeout int `json:"read_timeout,omitempty"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"` + URL string `json:"url" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` + SessionID string `json:"session_id,omitempty" yaml:"-"` + PingInterval int `json:"ping_interval,omitempty" yaml:"-"` + ReadTimeout int `json:"read_timeout,omitempty" yaml:"-"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"` } type IRCConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` - Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` - TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` - Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` - User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"` - RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"` - password string - nickServPassword string - SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` - saslPassword string - Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` - RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` - secDirty bool -} - -// Password returns the IRC password -func (c *IRCConfig) Password() string { - return c.password -} - -// NickServPassword returns the NickServ password -func (c *IRCConfig) NickServPassword() string { - return c.nickServPassword -} - -// SASLPassword returns the SASL password -func (c *IRCConfig) SASLPassword() string { - return c.saslPassword -} - -func (c *IRCConfig) SetPassword(password string) { - c.password = password - c.secDirty = true -} - -func (c *IRCConfig) SetNickServPassword(password string) { - c.nickServPassword = password - c.secDirty = true -} - -func (c *IRCConfig) SetSASLPassword(password string) { - c.saslPassword = password - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` + Server string `json:"server" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SERVER"` + TLS bool `json:"tls" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_TLS"` + Nick string `json:"nick" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_NICK"` + User string `json:"user,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_USER"` + RealName string `json:"real_name,omitempty" yaml:"-"` + Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` + NickServPassword SecureString `json:"nickserv_password,omitzero" yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` + SASLUser string `json:"sasl_user" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` + SASLPassword SecureString `json:"sasl_password,omitzero" yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` + Channels FlexibleStringSlice `json:"channels" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` + RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" yaml:"-"` + AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"` + Typing TypingConfig `json:"typing,omitempty" yaml:"-"` + ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"` } type HeartbeatConfig struct { @@ -889,10 +673,7 @@ type ModelConfig struct { ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body - // from security - secModelName string - apiKeys []string - secDirty bool + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) // isVirtual marks this model as a virtual model generated from multi-key expansion. // Virtual models should not be persisted to config files. @@ -901,8 +682,8 @@ type ModelConfig struct { // APIKey returns the first API key from apiKeys func (c *ModelConfig) APIKey() string { - if len(c.apiKeys) > 0 { - return c.apiKeys[0] + if len(c.APIKeys) > 0 { + return c.APIKeys[0].String() } return "" } @@ -924,12 +705,11 @@ func (c *ModelConfig) Validate() error { } func (c *ModelConfig) SetAPIKey(value string) { - if len(c.apiKeys) > 0 { - c.apiKeys[0] = value + if len(c.APIKeys) > 0 { + c.APIKeys[0].Set(value) } else { - c.apiKeys = append(c.apiKeys, value) + c.APIKeys = append(c.APIKeys, NewSecureString(value)) } - c.secDirty = true } type GatewayConfig struct { @@ -948,72 +728,58 @@ type ToolDiscoveryConfig struct { } type ToolConfig struct { - Enabled bool `json:"enabled" env:"ENABLED"` + Enabled bool `json:"enabled" yaml:"-" env:"ENABLED"` } type BraveConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - apiKeys []string - secDirty bool - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } // APIKey returns the Brave API key func (c *BraveConfig) APIKey() string { - if len(c.apiKeys) == 0 { + if len(c.APIKeys) == 0 { return "" } - return c.apiKeys[0] -} - -// APIKeys returns the Brave API keys -func (c *BraveConfig) APIKeys() []string { - return c.apiKeys + return c.APIKeys[0].String() } // SetAPIKey sets the Brave API key func (c *BraveConfig) SetAPIKey(key string) { - c.apiKeys = []string{key} - c.secDirty = true + c.APIKeys = SimpleSecureStrings(key) } -// SetAPIKeys sets the Brave API keys func (c *BraveConfig) SetAPIKeys(keys []string) { - c.apiKeys = keys - c.secDirty = true + c.APIKeys = SimpleSecureStrings(keys...) } type TavilyConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - apiKeys []string - secDirty bool - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } // APIKey returns the Tavily API key func (c *TavilyConfig) APIKey() string { - if len(c.apiKeys) == 0 { + if len(c.APIKeys) == 0 { return "" } - return c.apiKeys[0] -} - -// APIKeys returns the Tavily API keys -func (c *TavilyConfig) APIKeys() []string { - return c.apiKeys + return c.APIKeys[0].String() } // SetAPIKey sets the Tavily API key func (c *TavilyConfig) SetAPIKey(key string) { - c.apiKeys = []string{key} - c.secDirty = true + c.APIKeys = SimpleSecureStrings(key) } // SetAPIKeys sets the Tavily API keys func (c *TavilyConfig) SetAPIKeys(keys []string) { - c.apiKeys = keys - c.secDirty = true + c.APIKeys = make(SecureStrings, len(keys)) + for i, k := range keys { + c.APIKeys[i] = NewSecureString(k) + } } type DuckDuckGoConfig struct { @@ -1022,35 +788,22 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - apiKeys []string - secDirty bool - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } // APIKey returns the Perplexity API key func (c *PerplexityConfig) APIKey() string { - if len(c.apiKeys) == 0 { + if len(c.APIKeys) == 0 { return "" } - return c.apiKeys[0] + return c.APIKeys[0].String() } // SetAPIKey sets the Perplexity API key func (c *PerplexityConfig) SetAPIKey(key string) { - c.apiKeys = []string{key} - c.secDirty = true -} - -// APIKeys returns the Perplexity API keys -func (c *PerplexityConfig) APIKeys() []string { - return c.apiKeys -} - -// SetAPIKeys sets the Perplexity API keys -func (c *PerplexityConfig) SetAPIKeys(keys []string) { - c.apiKeys = keys - c.secDirty = true + c.APIKeys = SimpleSecureStrings(key) } type SearXNGConfig struct { @@ -1060,95 +813,72 @@ type SearXNGConfig struct { } type GLMSearchConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` - apiKey string - secDirty bool - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` + APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". - SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` -} - -// APIKey returns the GLM search API key -func (c *GLMSearchConfig) APIKey() string { - return c.apiKey -} - -// SetAPIKey sets the GLM search API key (internal use only) -func (c *GLMSearchConfig) SetAPIKey(key string) { - c.apiKey = key - c.secDirty = true + SearchEngine string `json:"search_engine" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } type BaiduSearchConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` - apiKey string - secDirty bool -} - -// APIKey returns the Baidu search API key -func (c *BaiduSearchConfig) APIKey() string { - return c.apiKey -} - -func (c *BaiduSearchConfig) SetAPIKey(key string) { - c.apiKey = key - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` + APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` } type WebToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` - Brave BraveConfig ` json:"brave"` - Tavily TavilyConfig ` json:"tavily"` - DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` - Perplexity PerplexityConfig ` json:"perplexity"` - SearXNG SearXNGConfig ` json:"searxng"` - GLMSearch GLMSearchConfig ` json:"glm_search"` - BaiduSearch BaiduSearchConfig ` json:"baidu_search"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` + Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` + Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` + DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` + Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"` + SearXNG SearXNGConfig `yaml:"-" json:"searxng"` + GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"` + BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"` // PreferNative controls whether to use provider-native web search when // the active LLM supports it (e.g. OpenAI web_search_preview). When true, // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. - PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` + PreferNative bool `json:"prefer_native" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` - FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` - Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` - PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` + Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` + Format string `json:"format,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_FORMAT"` + PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` - ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout - AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"` + ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout + AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"` } type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` - EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` - AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"` - CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` - CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` - TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) + EnableDenyPatterns bool ` json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + AllowRemote bool ` json:"allow_remote" env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE"` + CustomDenyPatterns []string ` json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` + CustomAllowPatterns []string ` json:"custom_allow_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS"` + TimeoutSeconds int ` json:"timeout_seconds" env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS"` // 0 means use default (60s) } type SkillsToolsConfig struct { - ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` - Registries SkillsRegistriesConfig ` json:"registries"` - Github SkillsGithubConfig ` json:"github"` - MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` - SearchCache SearchCacheConfig ` json:"search_cache"` + ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_SKILLS_"` + Registries SkillsRegistriesConfig `yaml:",inline,omitempty" json:"registries"` + Github SkillsGithubConfig `yaml:"github,omitempty" json:"github"` + MaxConcurrentSearches int `yaml:"-" json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` + SearchCache SearchCacheConfig `yaml:"-" json:"search_cache"` } type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` - MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` - Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` + MaxAge int ` json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Interval int ` json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` } type ReadFileToolConfig struct { @@ -1157,37 +887,37 @@ type ReadFileToolConfig struct { } type ToolsConfig struct { - AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` - AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` + AllowReadPaths []string `json:"allow_read_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` + AllowWritePaths []string `json:"allow_write_paths" yaml:"-" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) - FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` + FilterSensitiveData bool `json:"filter_sensitive_data" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 - FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` - Web WebToolsConfig `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` - Skills SkillsToolsConfig `json:"skills"` - MediaCleanup MediaCleanupConfig `json:"media_cleanup"` - MCP MCPConfig `json:"mcp"` - AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` - EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` - FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` - I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` - InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` - ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` - Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` - SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` - Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` - SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` - SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` - Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` - WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` - WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` + FilterMinLength int `json:"filter_min_length" yaml:"-" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` + Web WebToolsConfig `json:"web" yaml:"web,omitempty"` + Cron CronToolsConfig `json:"cron" yaml:"-"` + Exec ExecConfig `json:"exec" yaml:"-"` + Skills SkillsToolsConfig `json:"skills" yaml:"skills,omitempty"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup" yaml:"-"` + MCP MCPConfig `json:"mcp" yaml:"-"` + AppendFile ToolConfig `json:"append_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` + EditFile ToolConfig `json:"edit_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` + FindSkills ToolConfig `json:"find_skills" yaml:"-" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` + I2C ToolConfig `json:"i2c" yaml:"-" envPrefix:"PICOCLAW_TOOLS_I2C_"` + InstallSkill ToolConfig `json:"install_skill" yaml:"-" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` + ListDir ToolConfig `json:"list_dir" yaml:"-" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` + Message ToolConfig `json:"message" yaml:"-" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` + ReadFile ReadFileToolConfig `json:"read_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + SendFile ToolConfig `json:"send_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + Spawn ToolConfig `json:"spawn" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` + SpawnStatus ToolConfig `json:"spawn_status" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` + SPI ToolConfig `json:"spi" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SPI_"` + Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` + WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled @@ -1209,48 +939,24 @@ type SearchCacheConfig struct { } type SkillsRegistriesConfig struct { - ClawHub ClawHubRegistryConfig `json:"clawhub"` + ClawHub ClawHubRegistryConfig `json:"clawhub" yaml:"clawhub,omitempty"` } type SkillsGithubConfig struct { - token string - secDirty bool - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` -} - -// Token returns the GitHub token -func (c *SkillsGithubConfig) Token() string { - return c.token -} - -// SetToken sets the GitHub token -func (c *SkillsGithubConfig) SetToken(token string) { - c.token = token - c.secDirty = true + Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN"` + Proxy string `json:"proxy,omitempty" yaml:"-" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } type ClawHubRegistryConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` - BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` - authToken string - secDirty bool - SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` - SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` - DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` - Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` - MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` - MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` -} - -// AuthToken returns the ClawHub auth token -func (c *ClawHubRegistryConfig) AuthToken() string { - return c.authToken -} - -// SetAuthToken sets the ClawHub auth token -func (c *ClawHubRegistryConfig) SetAuthToken(token string) { - c.authToken = token - c.secDirty = true + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` + AuthToken SecureString `json:"auth_token,omitzero" yaml:"auth_token,omitempty" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_AUTH_TOKEN"` + SearchPath string `json:"search_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` + SkillsPath string `json:"skills_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` + DownloadPath string `json:"download_path" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` + Timeout int `json:"timeout" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` + MaxZipSize int `json:"max_zip_size" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` + MaxResponseSize int `json:"max_response_size" yaml:"-" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` } // MCPServerConfig defines configuration for a single MCP server @@ -1287,6 +993,9 @@ type MCPConfig struct { func LoadConfig(path string) (*Config, error) { logger.Debugf("loading config from %s", path) + + updateResolver(filepath.Dir(path)) + data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { @@ -1306,7 +1015,7 @@ func LoadConfig(path string) (*Config, error) { } if len(data) <= 10 { logger.Warn(fmt.Sprintf("content is [%s]", string(data))) - return DefaultConfig().WithSecurity(&SecurityConfig{}), nil + return DefaultConfig(), nil } // Load config based on detected version @@ -1330,16 +1039,10 @@ func LoadConfig(path string) (*Config, error) { return nil, err } // Load existing security config and merge with migrated one to prevent data loss - existingSec, secErr := loadSecurityConfig(securityPath(path)) - if secErr != nil { + secErr := loadSecurityConfig(cfg, securityPath(path)) + if secErr != nil && !os.IsNotExist(secErr) { 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}) - } + return nil, fmt.Errorf("failed to load existing security config: %w", secErr) } defer func(cfg *Config) { _ = SaveConfig(path, cfg) @@ -1350,60 +1053,21 @@ func LoadConfig(path string) (*Config, error) { if err != nil { return nil, err } - - // Legacy config (no version field) - tmpCfg, e := loadConfigV0(data) - if e != nil { - return nil, e - } - - tmpCfgMigrated, e := tmpCfg.Migrate() - if e != nil { - logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) - return nil, e - } - - // Load security configuration from .security.yml + // Load security configuration secPath := securityPath(path) - sec, err := loadSecurityConfig(secPath) - if err != nil { + err = loadSecurityConfig(cfg, secPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("failed to load security config: %w", err) } - // Merge security configs: config.json takes precedence over .security.yml - if err := applySecurityConfigWithPrecedence(cfg, tmpCfgMigrated, sec); err != nil { - return nil, fmt.Errorf("failed to merge security config: %w", err) - } default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } - if passphrase := credential.PassphraseProvider(); passphrase != "" { - for _, m := range cfg.ModelList { - for _, k := range m.apiKeys { - if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") { - fmt.Fprintf(os.Stderr, - "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", - m.ModelName) - break // Only warn once per model - } - } - } - } - if err := env.Parse(cfg); err != nil { return nil, err } - if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { - return nil, err - } - - // Resolve security fields like authToken that may contain file:// references - if err := resolveSecurityFields(cfg, filepath.Dir(path)); err != nil { - return nil, err - } - // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) @@ -1442,191 +1106,6 @@ func makeBackup(path string) error { return nil } -func copyArray[T any](dst, src *[]T) { - *dst = make([]T, len(*src)) - copy(*dst, *src) -} - -// applySecurityConfig resolves all security references in config -// It checks each field for "ref:" prefixed values and resolves them from .security.yml -func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { - if sec == nil { - return nil - } - - 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.Skills != nil { - 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 - } - } - - names := toNameIndex(cfg.ModelList) - for i, model := range cfg.ModelList { - // Try exact match first (e.g., "abc:0" -> "abc:0") - if entry, exists := sec.ModelList[names[i]]; exists { - copyArray(&model.apiKeys, &entry.APIKeys) - model.secModelName = names[i] - continue - } - - // Try match without index suffix (e.g., "abc" -> "abc") - // This allows .security.yml to use simpler keys like "test-model" instead of "test-model:0" - baseName := model.ModelName - if entry, exists := sec.ModelList[baseName]; exists { - copyArray(&model.apiKeys, &entry.APIKeys) - model.secModelName = baseName - continue - } - } - - 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 - } - 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 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 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 - } - } - - // 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 - } - 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 WeCom bot secret - if sec.Channels.WeCom != nil { - if sec.Channels.WeCom.Secret != "" { - cfg.Channels.WeCom.secret = sec.Channels.WeCom.Secret - } - } - - // 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 - } - 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 - } - } - - cfg.security = sec - - return nil -} - -// applySecurityConfigWithPrecedence merges security config from tmpCfg (migrated from configV0) and sec (SecurityConfig), -// with tmpCfg taking precedence. It then applies the merged security config to cfg. -func applySecurityConfigWithPrecedence(cfg *Config, tmpCfg *Config, sec *SecurityConfig) error { - // Get security config from tmpCfg (already extracted during migration) - var tmpSec *SecurityConfig - if tmpCfg != nil { - tmpSec = tmpCfg.security - } - - // If tmpCfg has no security config, just apply sec directly - if tmpSec == nil { - return applySecurityConfig(cfg, sec) - } - - // Merge sec and tmpSec, with tmpSec (from config.json) taking precedence - // mergeSecurityConfig(existing, newer) - newer takes precedence - mergedSec := mergeSecurityConfig(sec, tmpSec) - - // Apply the merged security config to cfg - return applySecurityConfig(cfg, mergedSec) -} - func toNameIndex(list []*ModelConfig) []string { nameList := make([]string, 0, len(list)) countMap := make(map[string]int) @@ -1639,66 +1118,6 @@ func toNameIndex(list []*ModelConfig) []string { return nameList } -// encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values -// encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or -// empty). Returns (nil, error) if any key fails to encrypt — callers must treat -// this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. -// Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig -// and leave JSON marshaling to the caller. -func encryptPlaintextAPIKeys( - models map[string]ModelSecurityEntry, - passphrase string, -) (map[string]ModelSecurityEntry, error) { - sealed := make(map[string]ModelSecurityEntry, len(models)) - changed := false - for k, m := range models { - sealedEntry := ModelSecurityEntry{APIKeys: make([]string, len(m.APIKeys))} - - // Encrypt each key in APIKeys - for i, key := range m.APIKeys { - if key == "" || strings.HasPrefix(key, "enc://") || strings.HasPrefix(key, "file://") { - sealedEntry.APIKeys[i] = key - continue - } - encrypted, err := credential.Encrypt(passphrase, "", key) - if err != nil { - return nil, fmt.Errorf("cannot seal api_key for model %q: %w", k, err) - } - sealedEntry.APIKeys[i] = encrypted - changed = true - } - - sealed[k] = sealedEntry - } - if !changed { - return nil, nil - } - return sealed, nil -} - -// resolveAPIKeys decrypts or dereferences each api_key in models in-place. -// Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). -func resolveAPIKeys(models []*ModelConfig, configDir string) error { - cr := credential.NewResolver(configDir) - for i := range models { - // Resolve APIKeys array - for j, key := range models[i].apiKeys { - resolved, err := cr.Resolve(key) - if err != nil { - return fmt.Errorf( - "model_list[%d] (%s): api_keys[%d]: %w", - i, - models[i].ModelName, - j, - err, - ) - } - models[i].apiKeys[j] = resolved - } - } - return nil -} - func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { @@ -1713,172 +1132,9 @@ func (c *Config) migrateChannelConfigs() { } func SaveConfig(path string, cfg *Config) error { - if cfg.security == nil { - logger.Errorf("config %#v", *cfg) - if len(cfg.ModelList) > 0 { - logger.Errorf("model[0] %#v", cfg.ModelList[0]) - } - logger.ErrorC("config", "security is nil") - return fmt.Errorf("security is nil") - } - cfg.security = normalizeSecurityConfig(cfg.security) - // Ensure version is always set when saving - if cfg.Version == 0 { + if cfg.Version < CurrentVersion { cfg.Version = CurrentVersion } - names := toNameIndex(cfg.ModelList) - for i, m := range cfg.ModelList { - if m.secDirty { - if m.secModelName == "" { - m.secModelName = names[i] - } - cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{ - APIKeys: m.apiKeys, - } - m.secDirty = false - } - } - if cfg.Channels.Pico.secDirty { - cfg.security.Channels.Pico = &PicoSecurity{ - Token: cfg.Channels.Pico.Token(), - } - cfg.Channels.Pico.secDirty = false - } - if cfg.Channels.IRC.secDirty { - cfg.security.Channels.IRC = &IRCSecurity{ - Password: cfg.Channels.IRC.password, - NickServPassword: cfg.Channels.IRC.nickServPassword, - SASLPassword: cfg.Channels.IRC.saslPassword, - } - cfg.Channels.IRC.secDirty = false - } - if cfg.Channels.Telegram.secDirty { - cfg.security.Channels.Telegram = &TelegramSecurity{ - Token: cfg.Channels.Telegram.Token(), - } - cfg.Channels.Telegram.secDirty = false - } - if cfg.Channels.Feishu.secDirty { - cfg.security.Channels.Feishu = &FeishuSecurity{ - AppSecret: cfg.Channels.Feishu.AppSecret(), - EncryptKey: cfg.Channels.Feishu.EncryptKey(), - VerificationToken: cfg.Channels.Feishu.VerificationToken(), - } - cfg.Channels.Feishu.secDirty = false - } - if cfg.Channels.Discord.secDirty { - cfg.security.Channels.Discord = &DiscordSecurity{ - Token: cfg.Channels.Discord.Token(), - } - cfg.Channels.Discord.secDirty = false - } - if cfg.Channels.Weixin.secDirty { - cfg.security.Channels.Weixin = &WeixinSecurity{ - Token: cfg.Channels.Weixin.Token(), - } - cfg.Channels.Discord.secDirty = false - } - if cfg.Channels.QQ.secDirty { - cfg.security.Channels.QQ = &QQSecurity{ - AppSecret: cfg.Channels.QQ.AppSecret(), - } - cfg.Channels.QQ.secDirty = false - } - if cfg.Channels.DingTalk.secDirty { - cfg.security.Channels.DingTalk = &DingTalkSecurity{ - ClientSecret: cfg.Channels.DingTalk.ClientSecret(), - } - cfg.Channels.DingTalk.secDirty = false - } - if cfg.Channels.Slack.secDirty { - cfg.security.Channels.Slack = &SlackSecurity{ - BotToken: cfg.Channels.Slack.BotToken(), - AppToken: cfg.Channels.Slack.AppToken(), - } - cfg.Channels.Slack.secDirty = false - } - if cfg.Channels.Matrix.secDirty { - cfg.security.Channels.Matrix = &MatrixSecurity{ - AccessToken: cfg.Channels.Matrix.AccessToken(), - } - cfg.Channels.Matrix.secDirty = false - } - if cfg.Channels.LINE.secDirty { - cfg.security.Channels.LINE = &LINESecurity{ - ChannelSecret: cfg.Channels.LINE.ChannelSecret(), - ChannelAccessToken: cfg.Channels.LINE.ChannelAccessToken(), - } - cfg.Channels.LINE.secDirty = false - } - if cfg.Channels.OneBot.secDirty { - cfg.security.Channels.OneBot = &OneBotSecurity{ - AccessToken: cfg.Channels.OneBot.AccessToken(), - } - cfg.Channels.OneBot.secDirty = false - } - if cfg.Channels.WeCom.secDirty { - cfg.security.Channels.WeCom = &WeComSecurity{ - Secret: cfg.Channels.WeCom.Secret(), - } - cfg.Channels.WeCom.secDirty = false - } - if cfg.Tools.Web.Brave.secDirty { - cfg.security.Web.Brave = &BraveSecurity{ - APIKeys: cfg.Tools.Web.Brave.APIKeys(), - } - cfg.Tools.Web.Brave.secDirty = false - } - if cfg.Tools.Web.Tavily.secDirty { - cfg.security.Web.Tavily = &TavilySecurity{ - APIKeys: cfg.Tools.Web.Tavily.APIKeys(), - } - cfg.Tools.Web.Tavily.secDirty = false - } - if cfg.Tools.Web.Perplexity.secDirty { - cfg.security.Web.Perplexity = &PerplexitySecurity{ - APIKeys: cfg.Tools.Web.Perplexity.APIKeys(), - } - cfg.Tools.Web.Perplexity.secDirty = false - } - if cfg.Tools.Web.GLMSearch.secDirty { - cfg.security.Web.GLMSearch = &GLMSearchSecurity{ - APIKey: cfg.Tools.Web.GLMSearch.APIKey(), - } - cfg.Tools.Web.GLMSearch.secDirty = false - } - if cfg.Tools.Web.BaiduSearch.secDirty { - cfg.security.Web.BaiduSearch = &BaiduSearchSecurity{ - APIKey: cfg.Tools.Web.BaiduSearch.APIKey(), - } - cfg.Tools.Web.BaiduSearch.secDirty = false - } - if cfg.Tools.Skills.Github.secDirty { - cfg.security.Skills.Github = &GithubSecurity{ - Token: cfg.Tools.Skills.Github.Token(), - } - cfg.Tools.Skills.Github.secDirty = false - } - if cfg.Tools.Skills.Registries.ClawHub.secDirty { - cfg.security.Skills.ClawHub = &ClawHubSecurity{ - AuthToken: cfg.Tools.Skills.Registries.ClawHub.AuthToken(), - } - cfg.Tools.Skills.Registries.ClawHub.secDirty = false - } - - if passphrase := credential.PassphraseProvider(); passphrase != "" { - sealed, err := encryptPlaintextAPIKeys(cfg.security.ModelList, passphrase) - if err != nil { - return err - } - if sealed != nil { - cfg.security.ModelList = sealed - } - } - if err := saveSecurityConfig(securityPath(path), cfg.security); err != nil { - logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err}) - return err - } - // Filter out virtual models before serializing to config file nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList)) for _, m := range cfg.ModelList { @@ -1890,6 +1146,11 @@ func SaveConfig(path string, cfg *Config) error { originalModelList := cfg.ModelList cfg.ModelList = nonVirtualModels + if err := saveSecurityConfig(securityPath(path), cfg); err != nil { + logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err}) + return err + } + data, err := json.MarshalIndent(cfg, "", " ") // Restore original ModelList after serialization cfg.ModelList = originalModelList @@ -1958,19 +1219,8 @@ func (c *Config) ValidateModelList() error { return nil } -func (c *Config) SecurityCopyFrom(cfg *Config) { - c.security = cfg.security - if c.security != nil { - if err := applySecurityConfig(c, c.security); err != nil { - logger.Errorf("failed to apply security config in SecurityCopyFrom: %v", err) - } - } -} - -// ApplySecurity re-applies the stored security config to populate private fields (tokens, API keys, etc.). -// Call this after SecurityCopyFrom when you need private fields to be accessible for validation or use. -func (c *Config) ApplySecurity() error { - return applySecurityConfig(c, c.security) +func (c *Config) SecurityCopyFrom(path string) error { + return loadSecurityConfig(c, securityPath(path)) } func MergeAPIKeys(apiKey string, apiKeys []string) []string { @@ -1996,74 +1246,6 @@ func MergeAPIKeys(apiKey string, apiKeys []string) []string { return all } -// resolveSecurityFields resolves file:// and enc:// references in security-sensitive fields -// like authToken and token that are not part of ModelConfig's apiKeys -func resolveSecurityFields(cfg *Config, configDir string) error { - cr := credential.NewResolver(configDir) - - // Resolve Web tool API keys - set apiKey field to first resolved apiKeys entry - if len(cfg.Tools.Web.Brave.apiKeys) > 0 { - keys := cfg.Tools.Web.Brave.apiKeys - for i, key := range keys { - resolved, err := cr.Resolve(key) - if err != nil { - return fmt.Errorf("brave api_keys[%d]: %w", i, err) - } - keys[i] = resolved - } - } - - if len(cfg.Tools.Web.Tavily.apiKeys) > 0 { - keys := cfg.Tools.Web.Tavily.apiKeys - for i, key := range keys { - resolved, err := cr.Resolve(key) - if err != nil { - return fmt.Errorf("tavily api_keys[%d]: %w", i, err) - } - keys[i] = resolved - } - } - - if len(cfg.Tools.Web.Perplexity.apiKeys) > 0 { - keys := cfg.Tools.Web.Perplexity.apiKeys - for i, key := range keys { - resolved, err := cr.Resolve(key) - if err != nil { - return fmt.Errorf("perplexity api_keys[%d]: %w", i, err) - } - keys[i] = resolved - } - } - - // GLMSearch has a private apiKey field - if cfg.Tools.Web.GLMSearch.apiKey != "" { - resolved, err := cr.Resolve(cfg.Tools.Web.GLMSearch.apiKey) - if err != nil { - return fmt.Errorf("glm api_key: %w", err) - } - cfg.Tools.Web.GLMSearch.apiKey = resolved - } - - // Resolve Skills tokens - if cfg.Tools.Skills.Github.token != "" { - resolved, err := cr.Resolve(cfg.Tools.Skills.Github.token) - if err != nil { - return fmt.Errorf("github token: %w", err) - } - cfg.Tools.Skills.Github.token = resolved - } - - if cfg.Tools.Skills.Registries.ClawHub.authToken != "" { - resolved, err := cr.Resolve(cfg.Tools.Skills.Registries.ClawHub.authToken) - if err != nil { - return fmt.Errorf("clawhub auth_token: %w", err) - } - cfg.Tools.Skills.Registries.ClawHub.authToken = resolved - } - - return nil -} - // expandMultiKeyModels expands ModelConfig entries with multiple API keys into // separate entries for key-level failover. Each key gets its own ModelConfig entry, // and the original entry's fallbacks are set up to chain through the expanded entries. @@ -2077,11 +1259,10 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig for _, m := range models { - keys := MergeAPIKeys("", m.apiKeys) + keys := m.APIKeys.Values() // Single key or no keys: keep as-is if len(keys) <= 1 { - m.apiKeys = keys expanded = append(expanded, m) continue } @@ -2100,7 +1281,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { ModelName: expandedName, Model: m.Model, APIBase: m.APIBase, - apiKeys: []string{keys[i]}, + APIKeys: SimpleSecureStrings(keys[i]), Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, @@ -2130,7 +1311,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, - apiKeys: []string{keys[0]}, + APIKeys: SimpleSecureStrings(keys[0]), } // Prepend new fallbacks to existing ones diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go index ad31833a3..fd54c9e08 100644 --- a/pkg/config/config_old.go +++ b/pkg/config/config_old.go @@ -102,53 +102,39 @@ type channelsConfigV0 struct { IRC ircConfigV0 `json:"irc"` } -func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) { - telegram, telegramSecurity := v.Telegram.ToTelegramConfig() - feishu, feishuSecurity := v.Feishu.ToFeishuConfig() - discord, discordSecurity := v.Discord.ToDiscordConfig() +func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig { + telegram := v.Telegram.ToTelegramConfig() + feishu := v.Feishu.ToFeishuConfig() + discord := v.Discord.ToDiscordConfig() maixcam := v.MaixCam.ToMaixCamConfig() - qq, qqSecurity := v.QQ.ToQQConfig() - weixin, weixinSecurity := v.Weixin.ToWeiXinConfig() - dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig() - slack, slackSecurity := v.Slack.ToSlackConfig() - matrix, matrixSecurity := v.Matrix.ToMatrixConfig() - line, lineSecurity := v.LINE.ToLINEConfig() - onebot, onebotSecurity := v.OneBot.ToOneBotConfig() - wecom, wecomSecurity := v.WeCom.ToWeComConfig() - pico, picoSecurity := v.Pico.ToPicoConfig() - irc, ircSecurity := v.IRC.ToIRCConfig() + qq := v.QQ.ToQQConfig() + weixin := v.Weixin.ToWeiXinConfig() + dingtalk := v.DingTalk.ToDingTalkConfig() + slack := v.Slack.ToSlackConfig() + matrix := v.Matrix.ToMatrixConfig() + line := v.LINE.ToLINEConfig() + onebot := v.OneBot.ToOneBotConfig() + wecom := v.WeCom.ToWeComConfig() + pico := v.Pico.ToPicoConfig() + irc := v.IRC.ToIRCConfig() return ChannelsConfig{ - WhatsApp: v.WhatsApp, - Telegram: telegram, - Feishu: feishu, - Discord: discord, - MaixCam: maixcam, - QQ: qq, - Weixin: weixin, - DingTalk: dingtalk, - Slack: slack, - Matrix: matrix, - LINE: line, - OneBot: onebot, - WeCom: wecom, - 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, - Pico: picoSecurity, - IRC: ircSecurity, - } + WhatsApp: v.WhatsApp, + Telegram: telegram, + Feishu: feishu, + Discord: discord, + MaixCam: maixcam, + QQ: qq, + Weixin: weixin, + DingTalk: dingtalk, + Slack: slack, + Matrix: matrix, + LINE: line, + OneBot: onebot, + WeCom: wecom, + Pico: pico, + IRC: irc, + } } type qqConfigV0 struct { @@ -163,13 +149,7 @@ type qqConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } -func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) { - var sec *QQSecurity - if v.AppSecret != "" { - sec = &QQSecurity{ - AppSecret: v.AppSecret, - } - } +func (v *qqConfigV0) ToQQConfig() QQConfig { return QQConfig{ Enabled: v.Enabled, AppID: v.AppID, @@ -179,7 +159,8 @@ func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) { MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB, SendMarkdown: v.SendMarkdown, ReasoningChannelID: v.ReasoningChannelID, - }, sec + AppSecret: *NewSecureString(v.AppSecret), + } } type telegramConfigV0 struct { @@ -195,16 +176,9 @@ type telegramConfigV0 struct { UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` } -func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) { - var sec *TelegramSecurity - if v.Token != "" { - sec = &TelegramSecurity{ - Token: v.Token, - } - } - return TelegramConfig{ +func (v *telegramConfigV0) ToTelegramConfig() TelegramConfig { + cfg := TelegramConfig{ Enabled: v.Enabled, - token: v.Token, BaseURL: v.BaseURL, Proxy: v.Proxy, AllowFrom: v.AllowFrom, @@ -213,7 +187,11 @@ func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, UseMarkdownV2: v.UseMarkdownV2, - }, sec + } + if v.Token != "" { + cfg.Token = *NewSecureString(v.Token) + } + return cfg } type feishuConfigV0 struct { @@ -230,24 +208,25 @@ type feishuConfigV0 struct { IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` } -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{ +func (v *feishuConfigV0) ToFeishuConfig() FeishuConfig { + cfg := FeishuConfig{ Enabled: v.Enabled, AppID: v.AppID, - appSecret: v.AppSecret, AllowFrom: v.AllowFrom, GroupTrigger: v.GroupTrigger, Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.AppSecret != "" { + cfg.AppSecret = *NewSecureString(v.AppSecret) + } + if v.EncryptKey != "" { + cfg.EncryptKey = *NewSecureString(v.EncryptKey) + } + if v.VerificationToken != "" { + cfg.VerificationToken = *NewSecureString(v.VerificationToken) + } + return cfg } type discordConfigV0 struct { @@ -262,16 +241,9 @@ type discordConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } -func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) { - var sec *DiscordSecurity - if v.Token != "" { - sec = &DiscordSecurity{ - Token: v.Token, - } - } - return DiscordConfig{ +func (v *discordConfigV0) ToDiscordConfig() DiscordConfig { + cfg := DiscordConfig{ Enabled: v.Enabled, - token: v.Token, Proxy: v.Proxy, AllowFrom: v.AllowFrom, MentionOnly: v.MentionOnly, @@ -279,7 +251,11 @@ func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) { Typing: v.Typing, Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.Token != "" { + cfg.Token = *NewSecureString(v.Token) + } + return cfg } type maixcamConfigV0 struct { @@ -309,21 +285,18 @@ type dingtalkConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } -func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) { - var sec *DingTalkSecurity - if v.ClientSecret != "" { - sec = &DingTalkSecurity{ - ClientSecret: v.ClientSecret, - } - } - return DingTalkConfig{ +func (v *dingtalkConfigV0) ToDingTalkConfig() DingTalkConfig { + cfg := DingTalkConfig{ Enabled: v.Enabled, ClientID: v.ClientID, - clientSecret: v.ClientSecret, AllowFrom: v.AllowFrom, GroupTrigger: v.GroupTrigger, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.ClientSecret != "" { + cfg.ClientSecret = *NewSecureString(v.ClientSecret) + } + return cfg } type slackConfigV0 struct { @@ -337,24 +310,22 @@ type slackConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } -func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) { - var sec *SlackSecurity - if v.BotToken != "" || v.AppToken != "" { - sec = &SlackSecurity{ - BotToken: v.BotToken, - AppToken: v.AppToken, - } - } - return SlackConfig{ +func (v *slackConfigV0) ToSlackConfig() SlackConfig { + cfg := 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 + } + if v.BotToken != "" { + cfg.BotToken = *NewSecureString(v.BotToken) + } + if v.AppToken != "" { + cfg.AppToken = *NewSecureString(v.AppToken) + } + return cfg } type matrixConfigV0 struct { @@ -371,18 +342,11 @@ type matrixConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } -func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) { - var sec *MatrixSecurity - if v.AccessToken != "" { - sec = &MatrixSecurity{ - AccessToken: v.AccessToken, - } - } - return MatrixConfig{ +func (v *matrixConfigV0) ToMatrixConfig() MatrixConfig { + cfg := MatrixConfig{ Enabled: v.Enabled, Homeserver: v.Homeserver, UserID: v.UserID, - accessToken: v.AccessToken, DeviceID: v.DeviceID, JoinOnInvite: v.JoinOnInvite, MessageFormat: v.MessageFormat, @@ -390,7 +354,11 @@ func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) { GroupTrigger: v.GroupTrigger, Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.AccessToken != "" { + cfg.AccessToken = *NewSecureString(v.AccessToken) + } + return cfg } type lineConfigV0 struct { @@ -407,18 +375,9 @@ type lineConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` } -func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) { - var sec *LINESecurity - if v.ChannelSecret != "" || v.ChannelAccessToken != "" { - sec = &LINESecurity{ - ChannelSecret: v.ChannelSecret, - ChannelAccessToken: v.ChannelAccessToken, - } - } - return LINEConfig{ +func (v *lineConfigV0) ToLINEConfig() LINEConfig { + cfg := LINEConfig{ Enabled: v.Enabled, - channelSecret: v.ChannelSecret, - channelAccessToken: v.ChannelAccessToken, WebhookHost: v.WebhookHost, WebhookPort: v.WebhookPort, WebhookPath: v.WebhookPath, @@ -427,7 +386,14 @@ func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) { Typing: v.Typing, Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.ChannelSecret != "" { + cfg.ChannelSecret = *NewSecureString(v.ChannelSecret) + } + if v.ChannelAccessToken != "" { + cfg.ChannelAccessToken = *NewSecureString(v.ChannelAccessToken) + } + return cfg } type onebotConfigV0 struct { @@ -443,17 +409,10 @@ type onebotConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` } -func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) { - var sec *OneBotSecurity - if v.AccessToken != "" { - sec = &OneBotSecurity{ - AccessToken: v.AccessToken, - } - } - return OneBotConfig{ +func (v *onebotConfigV0) ToOneBotConfig() OneBotConfig { + cfg := OneBotConfig{ Enabled: v.Enabled, WSUrl: v.WSUrl, - accessToken: v.AccessToken, ReconnectInterval: v.ReconnectInterval, GroupTriggerPrefix: v.GroupTriggerPrefix, AllowFrom: v.AllowFrom, @@ -461,7 +420,11 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) { Typing: v.Typing, Placeholder: v.Placeholder, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.AccessToken != "" { + cfg.AccessToken = *NewSecureString(v.AccessToken) + } + return cfg } type wecomConfigV0 struct { @@ -478,20 +441,19 @@ type wecomConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"` } -func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) { - var sec *WeComSecurity - if v.Secret != "" { - sec = &WeComSecurity{Secret: v.Secret} - } - return WeComConfig{ +func (v *wecomConfigV0) ToWeComConfig() WeComConfig { + cfg := WeComConfig{ Enabled: v.Enabled, BotID: v.BotID, - secret: v.Secret, WebSocketURL: v.WebSocketURL, SendThinkingMessage: v.SendThinkingMessage, AllowFrom: v.AllowFrom, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.Secret != "" { + cfg.Secret = *NewSecureString(v.Secret) + } + return cfg } type weixinConfigV0 struct { @@ -504,22 +466,19 @@ type weixinConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` } -func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) { - var sec *WeixinSecurity - if v.Token != "" { - sec = &WeixinSecurity{ - Token: v.Token, - } - } - return WeixinConfig{ +func (v *weixinConfigV0) ToWeiXinConfig() WeixinConfig { + cfg := WeixinConfig{ Enabled: v.Enabled, - token: v.Token, BaseURL: v.BaseURL, CDNBaseURL: v.CDNBaseURL, Proxy: v.Proxy, AllowFrom: v.AllowFrom, ReasoningChannelID: v.ReasoningChannelID, - }, sec + } + if v.Token != "" { + cfg.Token = *NewSecureString(v.Token) + } + return cfg } type picoConfigV0 struct { @@ -535,16 +494,9 @@ type picoConfigV0 struct { Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } -func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) { - var sec *PicoSecurity - if v.Token != "" { - sec = &PicoSecurity{ - Token: v.Token, - } - } - return PicoConfig{ +func (v *picoConfigV0) ToPicoConfig() PicoConfig { + cfg := PicoConfig{ Enabled: v.Enabled, - token: v.Token, AllowTokenQuery: v.AllowTokenQuery, AllowOrigins: v.AllowOrigins, PingInterval: v.PingInterval, @@ -553,7 +505,11 @@ func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) { MaxConnections: v.MaxConnections, AllowFrom: v.AllowFrom, Placeholder: v.Placeholder, - }, sec + } + if v.Token != "" { + cfg.Token = *NewSecureString(v.Token) + } + return cfg } type ircConfigV0 struct { @@ -575,33 +531,32 @@ type ircConfigV0 struct { ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` } -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{ +func (v *ircConfigV0) ToIRCConfig() IRCConfig { + cfg := 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 + } + if v.Password != "" { + cfg.Password = *NewSecureString(v.Password) + } + if v.NickServPassword != "" { + cfg.NickServPassword = *NewSecureString(v.NickServPassword) + } + if v.SASLPassword != "" { + cfg.SASLPassword = *NewSecureString(v.SASLPassword) + } + return cfg } type providersConfigV0 struct { @@ -748,15 +703,12 @@ func (c *configV0) Migrate() (*Config, error) { // Copy other top-level fields cfg.Bindings = c.Bindings cfg.Session = c.Session - var secChannels ChannelsSecurity - cfg.Channels, secChannels = c.Channels.ToChannelsConfig() + cfg.Channels = c.Channels.ToChannelsConfig() cfg.Gateway = c.Gateway - var secWeb WebToolsSecurity - cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig() + cfg.Tools.Web = c.Tools.Web.ToWebToolsConfig() cfg.Tools.Cron = c.Tools.Cron cfg.Tools.Exec = c.Tools.Exec - var secSkills *SkillsSecurity - cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig() + cfg.Tools.Skills = c.Tools.Skills.ToSkillsToolsConfig() cfg.Tools.MediaCleanup = c.Tools.MediaCleanup cfg.Tools.MCP = c.Tools.MCP cfg.Tools.AppendFile = c.Tools.AppendFile @@ -778,15 +730,10 @@ func (c *configV0) Migrate() (*Config, error) { cfg.Heartbeat = c.Heartbeat cfg.Devices = c.Devices - secModels := make(map[string]ModelSecurityEntry, 0) - // Only override ModelList if user provided values if len(c.ModelList) > 0 { // Convert []modelConfigV0 to []ModelConfig cfg.ModelList = make([]*ModelConfig, len(c.ModelList)) for i, m := range c.ModelList { - // Merge APIKey and APIKeys, deduplicating - mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys) - cfg.ModelList[i] = &ModelConfig{ ModelName: m.ModelName, Model: m.Model, @@ -800,27 +747,11 @@ func (c *configV0) Migrate() (*Config, error) { MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, - apiKeys: mergedKeys, - } - } - names := toNameIndex(cfg.ModelList) - for i, m := range c.ModelList { - // Merge APIKey and APIKeys, deduplicating - mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys) - if len(mergedKeys) > 0 { - secModels[names[i]] = ModelSecurityEntry{ - APIKeys: mergedKeys, - } + APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)), } } } - cfg.WithSecurity(&SecurityConfig{ - ModelList: secModels, - Channels: &secChannels, - Web: &secWeb, - Skills: secSkills, - }) cfg.Version = CurrentVersion return cfg, nil } @@ -848,17 +779,20 @@ type braveConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } -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), - } +func toSecureStrings(keys []string) SecureStrings { + apikeys := make(SecureStrings, len(keys)) + for i, key := range keys { + apikeys[i] = NewSecureString(key) } + return apikeys +} + +func (v *braveConfigV0) ToBraveConfig() BraveConfig { return BraveConfig{ Enabled: v.Enabled, MaxResults: v.MaxResults, - }, sec + APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + } } type tavilyConfigV0 struct { @@ -869,18 +803,13 @@ type tavilyConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } -func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) { - var sec *TavilySecurity - if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 { - sec = &TavilySecurity{ - APIKeys: k, - } - } +func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig { return TavilyConfig{ Enabled: v.Enabled, BaseURL: v.BaseURL, MaxResults: v.MaxResults, - }, sec + APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + } } type perplexityConfigV0 struct { @@ -890,17 +819,12 @@ type perplexityConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } -func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) { - var sec *PerplexitySecurity - if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 { - sec = &PerplexitySecurity{ - APIKeys: k, - } - } +func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig { return PerplexityConfig{ Enabled: v.Enabled, MaxResults: v.MaxResults, - }, sec + APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)), + } } type glmSearchConfigV0 struct { @@ -910,19 +834,13 @@ type glmSearchConfigV0 struct { SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` } -func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) { - var sec *GLMSearchSecurity - if v.APIKey != "" { - sec = &GLMSearchSecurity{ - APIKey: v.APIKey, - } - } +func (v *glmSearchConfigV0) ToGLMSearchConfig() GLMSearchConfig { return GLMSearchConfig{ Enabled: v.Enabled, - apiKey: v.APIKey, + APIKey: *NewSecureString(v.APIKey), BaseURL: v.BaseURL, SearchEngine: v.SearchEngine, - }, sec + } } type baiduSearchConfigV0 struct { @@ -932,49 +850,37 @@ type baiduSearchConfigV0 struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` } -func (v *baiduSearchConfigV0) ToBaiduSearchConfig() (BaiduSearchConfig, *BaiduSearchSecurity) { - var sec *BaiduSearchSecurity - if v.APIKey != "" { - sec = &BaiduSearchSecurity{ - APIKey: v.APIKey, - } - } +func (v *baiduSearchConfigV0) ToBaiduSearchConfig() BaiduSearchConfig { return BaiduSearchConfig{ Enabled: v.Enabled, - apiKey: v.APIKey, + APIKey: *NewSecureString(v.APIKey), BaseURL: v.BaseURL, MaxResults: v.MaxResults, - }, sec + } } -func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) { - brave, braveSecurity := v.Brave.ToBraveConfig() - tavily, tavilySecurity := v.Tavily.ToTavilyConfig() - perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig() - glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig() - baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig() +func (v *webToolsConfigV0) ToWebToolsConfig() WebToolsConfig { + brave := v.Brave.ToBraveConfig() + tavily := v.Tavily.ToTavilyConfig() + perplexity := v.Perplexity.ToPerplexityConfig() + glmSearch := v.GLMSearch.ToGLMSearchConfig() + baiduSearch := v.BaiduSearch.ToBaiduSearchConfig() return WebToolsConfig{ - ToolConfig: v.ToolConfig, - Brave: brave, - Tavily: tavily, - DuckDuckGo: v.DuckDuckGo, - Perplexity: perplexity, - SearXNG: v.SearXNG, - GLMSearch: glmSearch, - BaiduSearch: baiduSearch, - PreferNative: v.PreferNative, - Proxy: v.Proxy, - FetchLimitBytes: v.FetchLimitBytes, - Format: v.Format, - PrivateHostWhitelist: v.PrivateHostWhitelist, - }, WebToolsSecurity{ - Brave: braveSecurity, - Tavily: tavilySecurity, - Perplexity: perplexitySecurity, - GLMSearch: glmSearchSecurity, - BaiduSearch: baiduSearchSecurity, - } + ToolConfig: v.ToolConfig, + Brave: brave, + Tavily: tavily, + DuckDuckGo: v.DuckDuckGo, + Perplexity: perplexity, + SearXNG: v.SearXNG, + GLMSearch: glmSearch, + PreferNative: v.PreferNative, + Proxy: v.Proxy, + FetchLimitBytes: v.FetchLimitBytes, + Format: v.Format, + PrivateHostWhitelist: v.PrivateHostWhitelist, + BaiduSearch: baiduSearch, + } } type skillsToolsConfigV0 struct { @@ -997,20 +903,17 @@ type clawHubRegistryConfigV0 struct { SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` } -func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) { - var sec *ClawHubSecurity - if v.AuthToken != "" { - sec = &ClawHubSecurity{ - AuthToken: v.AuthToken, - } - } - return ClawHubRegistryConfig{ +func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() ClawHubRegistryConfig { + cfg := ClawHubRegistryConfig{ Enabled: v.Enabled, BaseURL: v.BaseURL, - authToken: v.AuthToken, SearchPath: v.SearchPath, SkillsPath: v.SkillsPath, - }, sec + } + if v.AuthToken != "" { + cfg.AuthToken = *NewSecureString(v.AuthToken) + } + return cfg } type skillsGithubConfigV0 struct { @@ -1018,43 +921,29 @@ type skillsGithubConfigV0 struct { Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } -func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) { - var sec *GithubSecurity - if v.Token != "" { - sec = &GithubSecurity{ - Token: v.Token, - } - } +func (v *skillsGithubConfigV0) ToSkillsGithubConfig() SkillsGithubConfig { return SkillsGithubConfig{ - token: v.Token, + Token: *NewSecureString(v.Token), Proxy: v.Proxy, - }, sec + } } -func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) { - clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig() +func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() SkillsRegistriesConfig { + clawHub := v.ClawHub.ToClawHubRegistryConfig() return SkillsRegistriesConfig{ ClawHub: clawHub, - }, clawHubSecurity + } } -func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) { - registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig() - github, githubSecurity := v.Github.ToSkillsGithubConfig() - - var sec *SkillsSecurity - if githubSecurity != nil || registriesSecurity != nil { - sec = &SkillsSecurity{ - Github: githubSecurity, - ClawHub: registriesSecurity, - } - } +func (v *skillsToolsConfigV0) ToSkillsToolsConfig() SkillsToolsConfig { + registries := v.Registries.ToSkillsRegistriesConfig() + github := v.Github.ToSkillsGithubConfig() 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 ae4af0626..75eb458b8 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -309,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if len(cfg.Tools.Web.Brave.APIKeys()) != 0 { + if len(cfg.Tools.Web.Brave.APIKeys) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { @@ -403,12 +403,12 @@ func TestSaveConfig_FiltersVirtualModels(t *testing.T) { primaryModel := &ModelConfig{ ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"key1"}, + APIKeys: SimpleSecureStrings("key1"), } virtualModel := &ModelConfig{ ModelName: "gpt-4__key_1", Model: "openai/gpt-4o", - apiKeys: []string{"key2"}, + APIKeys: SimpleSecureStrings("key2"), isVirtual: true, } cfg.ModelList = []*ModelConfig{primaryModel, virtualModel} @@ -1064,11 +1064,10 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) { cfg := DefaultConfig() cfg.ModelList = []*ModelConfig{ - {ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}}, - } - cfg.security = &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}}, + {ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("")}, } + cfg.ModelList[0].APIKeys[0].Set("sk-plaintext") + if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } @@ -1132,8 +1131,9 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) { secPath := filepath.Join(dir, SecurityConfigFile) if err := saveSecurityConfig( secPath, - &SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}}, - ); err != nil { + &Config{ModelList: SecureModelList{ + &ModelConfig{ModelName: "test", APIKeys: SimpleSecureStrings("file://openai.key")}, + }}); err != nil { t.Fatalf("saveSecurityConfig: %v", err) } @@ -1165,12 +1165,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) { // Pre-encrypt one key so we have a genuine enc:// value to put in the config. if err := SaveConfig(cfgPath, &Config{ ModelList: []*ModelConfig{ - {ModelName: "pre", Model: "openai/gpt-4"}, - }, - security: &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "pre:0": {APIKeys: []string{"sk-already-plain"}}, - }, + {ModelName: "pre", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-already-plain")}, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) @@ -1199,23 +1194,18 @@ func TestSaveConfig_MixedKeys(t *testing.T) { t.Fatalf("setup: %v", err) } cfg := &Config{ + Version: CurrentVersion, ModelList: []*ModelConfig{ - {ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}}, - {ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}}, - {ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}}, - }, - security: &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "plain:0": {APIKeys: []string{"sk-new-plaintext"}}, - "enc:0": {APIKeys: []string{alreadyEncrypted}}, - "file:0": {APIKeys: []string{"file://api.key"}}, - }, + {ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")}, + {ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)}, + {ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("file://api.key")}, }, } if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } + t.Logf("alreadyEncrypted: %s", alreadyEncrypted) raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile)) s := string(raw) @@ -1265,20 +1255,16 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase") mustSetupSSHKey(t) if err := SaveConfig(cfgPath, &Config{ + Version: CurrentVersion, ModelList: []*ModelConfig{ - {ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}}, - }, - security: &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "m:0": {APIKeys: []string{"sk-secret"}}, - }, + {ModelName: "m", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-secret")}, }, }); err != nil { t.Fatalf("setup SaveConfig: %v", err) } raw, err := LoadConfig(cfgPath) assert.NoError(t, err) - encValue := raw.security.ModelList["m:0"].APIKeys[0] + encValue := raw.ModelList[0].APIKeys[0].raw assert.NotEmpty(t, encValue) assert.Equal(t, "enc://", encValue[:6]) @@ -1311,8 +1297,9 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) { // Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted. t.Setenv("PICOCLAW_KEY_PASSPHRASE", "") - _, err = LoadConfig(cfgPath) + cfg2, err := LoadConfig(cfgPath) if err == nil { + t.Logf("LoadConfig: %#v", cfg2.ModelList) t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set") } if !strings.Contains(err.Error(), "passphrase required") { @@ -1340,9 +1327,8 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) { cfg := DefaultConfig() cfg.ModelList = []*ModelConfig{ - {ModelName: "test", Model: "openai/gpt-4"}, + {ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-plaintext")}, } - cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}} if err := SaveConfig(cfgPath, cfg); err != nil { t.Fatalf("SaveConfig: %v", err) } @@ -1386,6 +1372,8 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) { credential.PassphraseProvider = func() string { return testPassphrase } t.Cleanup(func() { credential.PassphraseProvider = orig }) + t.Logf("cfgPath: %s", cfgPath) + cfg, err := LoadConfig(cfgPath) if err != nil { t.Fatalf("LoadConfig: %v", err) @@ -1435,17 +1423,15 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) { cfgPath := filepath.Join(dir, "config.json") cfg := &Config{ + Version: CurrentVersion, ModelList: []*ModelConfig{ { ModelName: "test-model", Model: "openai/test", - apiKeys: []string{"sk-test"}, + APIKeys: SimpleSecureStrings("sk-test"), ExtraBody: map[string]any{"custom_field": "value", "num_field": 42}, }, }, - security: &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{"test-model:0": {APIKeys: []string{"sk-test"}}}, - }, } if err := SaveConfig(cfgPath, cfg); err != nil { @@ -1497,20 +1483,25 @@ func TestFilterSensitiveData(t *testing.T) { } // Test with empty content - cfg.security = &SecurityConfig{} if got := cfg.FilterSensitiveData(""); got != "" { t.Errorf("empty content: got %q, want empty", got) } // Test short content (less than FilterMinLength=8, should skip filtering) - cfg.security.ModelList = map[string]ModelSecurityEntry{ - "test": {APIKeys: []string{"sk-long-key-12345"}}, + cfg.ModelList = SecureModelList{ + &ModelConfig{ + ModelName: "test", + APIKeys: SimpleSecureStrings("sk-long-key-12345"), + }, } + m, err := cfg.GetModelConfig("test") + assert.NoError(t, err) + m.APIKeys = SimpleSecureStrings("sk-long-key-12345") cfg.Tools.FilterSensitiveData = true cfg.Tools.FilterMinLength = 8 // Debug: check if sensitive values are collected - values := cfg.security.collectSensitiveValues() + values := cfg.collectSensitiveValues() t.Logf("collected %d sensitive values: %v", len(values), values) if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" { @@ -1538,11 +1529,17 @@ func TestFilterSensitiveData_MultipleKeys(t *testing.T) { FilterSensitiveData: true, FilterMinLength: 8, }, - } - cfg.security = &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "model1": {APIKeys: []string{"key-one", "key-two"}}, - "model2": {APIKeys: []string{"key-three"}}, + ModelList: SecureModelList{ + &ModelConfig{ + ModelName: "model1", + Model: "openai/model1", + APIKeys: SecureStrings{NewSecureString("key-one"), NewSecureString("key-two")}, + }, + &ModelConfig{ + ModelName: "model2", + Model: "openai/model2", + APIKeys: SecureStrings{NewSecureString("key-three")}, + }, }, } @@ -1555,45 +1552,54 @@ func TestFilterSensitiveData_MultipleKeys(t *testing.T) { func TestFilterSensitiveData_AllTokenTypes(t *testing.T) { cfg := &Config{ + // Model API keys + ModelList: SecureModelList{ + &ModelConfig{ + ModelName: "test-model", + APIKeys: SecureStrings{NewSecureString("sk-model-key-12345")}, + }, + }, + // Channel tokens + Channels: ChannelsConfig{ + Telegram: TelegramConfig{Token: *NewSecureString("telegram-bot-token-abcdef")}, + Discord: DiscordConfig{Token: *NewSecureString("discord-bot-token-xyz789")}, + Slack: SlackConfig{ + BotToken: *NewSecureString("xoxb-slack-bot-token"), + AppToken: *NewSecureString("xapp-slack-app-token"), + }, + Matrix: MatrixConfig{AccessToken: *NewSecureString("matrix-access-token-abc")}, + Feishu: FeishuConfig{ + AppSecret: *NewSecureString("feishu-app-secret-123"), + EncryptKey: *NewSecureString("feishu-encrypt-key"), + }, + DingTalk: DingTalkConfig{ClientSecret: *NewSecureString("dingtalk-client-secret")}, + OneBot: OneBotConfig{AccessToken: *NewSecureString("onebot-access-token")}, + WeCom: WeComConfig{Secret: *NewSecureString("wecom-secret")}, + Pico: PicoConfig{Token: *NewSecureString("pico-token-abc123")}, + IRC: IRCConfig{ + Password: *NewSecureString("irc-password"), + NickServPassword: *NewSecureString("nickserv-pass"), + SASLPassword: *NewSecureString("sasl-pass"), + }, + }, Tools: ToolsConfig{ FilterSensitiveData: true, FilterMinLength: 8, - }, - } - cfg.security = &SecurityConfig{ - // Model API keys - ModelList: map[string]ModelSecurityEntry{ - "test-model": {APIKeys: []string{"sk-model-key-12345"}}, - }, - // Channel tokens - 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"}, - Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"}, - Feishu: &FeishuSecurity{AppSecret: "feishu-app-secret-123", EncryptKey: "feishu-encrypt-key"}, - DingTalk: &DingTalkSecurity{ClientSecret: "dingtalk-client-secret"}, - OneBot: &OneBotSecurity{AccessToken: "onebot-access-token"}, - WeCom: &WeComSecurity{Secret: "wecom-secret"}, - Pico: &PicoSecurity{Token: "pico-token-abc123"}, - IRC: &IRCSecurity{ - Password: "irc-password", - NickServPassword: "nickserv-pass", - SASLPassword: "sasl-pass", + // Web tool API keys + Web: WebToolsConfig{ + Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}}, + Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}}, + Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}}, + GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")}, + BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")}, + }, + // Skills tokens + Skills: SkillsToolsConfig{ + Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")}, + Registries: SkillsRegistriesConfig{ + ClawHub: ClawHubRegistryConfig{AuthToken: *NewSecureString("clawhub-auth-token")}, + }, }, - }, - // Web tool API keys - Web: &WebToolsSecurity{ - Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}}, - Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}}, - Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}}, - GLMSearch: &GLMSearchSecurity{APIKey: "glm-search-key"}, - BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"}, - }, - // Skills tokens - 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 2776a78a3..bc4ab0649 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -519,11 +519,5 @@ func DefaultConfig() *Config { BuildTime: BuildTime, GoVersion: GoVersion, }, - security: &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{}, - Channels: &ChannelsSecurity{}, - Web: &WebToolsSecurity{}, - Skills: &SkillsSecurity{}, - }, } } diff --git a/pkg/config/migration_integration_test.go b/pkg/config/migration_integration_test.go index 49d2a5831..bc8160967 100644 --- a/pkg/config/migration_integration_test.go +++ b/pkg/config/migration_integration_test.go @@ -103,8 +103,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) { if !cfg.Channels.Telegram.Enabled { t.Error("Telegram.Enabled should be true") } - if cfg.Channels.Telegram.Token() != "test-token" { - t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token") + if cfg.Channels.Telegram.Token.String() != "test-token" { + t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token.String(), "test-token") } if cfg.Gateway.Port != 18790 { t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790) @@ -643,15 +643,15 @@ web: // 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" { + if cfg.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" { t.Errorf("Telegram token was overwritten: got %q, want %q", - cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env") + cfg.Channels.Telegram.Token.String(), "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" { + if cfg.Channels.Discord.Token.String() != "existing-discord-token-from-env" { t.Errorf("Discord token was overwritten: got %q, want %q", - cfg.Channels.Discord.Token(), "existing-discord-token-from-env") + cfg.Channels.Discord.Token.String(), "existing-discord-token-from-env") } // Model API key should be preserved @@ -667,17 +667,17 @@ web: } // Reload the security config from disk to verify it wasn't corrupted - reloadedSec, err := loadSecurityConfig(securityPath) + reloadedSec := cfg + err = loadSecurityConfig(cfg, 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" { + if reloadedSec.Channels.Telegram.Token.String() != "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" { + if reloadedSec.Channels.Discord.Token.String() != "existing-discord-token-from-env" { t.Error("Discord token not preserved in .security.yml file") } } diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 3252d2f26..6e88f4783 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -13,20 +13,13 @@ import ( ) func TestGetModelConfig_Found(t *testing.T) { - cfg := (&Config{ + cfg := &Config{ Version: CurrentVersion, ModelList: []*ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o"}, - {ModelName: "other-model", Model: "anthropic/claude"}, + {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")}, }, - }).WithSecurity(&SecurityConfig{ModelList: map[string]ModelSecurityEntry{ - "test-model:0": { - APIKeys: []string{"key1"}, - }, - "other-model:0": { - APIKeys: []string{"key2"}, - }, - }}) + } result, err := cfg.GetModelConfig("test-model") if err != nil { @@ -38,17 +31,11 @@ func TestGetModelConfig_Found(t *testing.T) { } func TestGetModelConfig_NotFound(t *testing.T) { - cfg := (&Config{ + cfg := &Config{ ModelList: []*ModelConfig{ - {ModelName: "test-model", Model: "openai/gpt-4o"}, + {ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")}, }, - }).WithSecurity(&SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "test-model:0": { - APIKeys: []string{"key1"}, - }, - }, - }) + } _, err := cfg.GetModelConfig("nonexistent") if err == nil { @@ -68,25 +55,13 @@ func TestGetModelConfig_EmptyList(t *testing.T) { } func TestGetModelConfig_RoundRobin(t *testing.T) { - cfg := (&Config{ + cfg := &Config{ ModelList: []*ModelConfig{ - {ModelName: "lb-model", Model: "openai/gpt-4o-1"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-2"}, - {ModelName: "lb-model", Model: "openai/gpt-4o-3"}, + {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")}, }, - }).WithSecurity(&SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "lb-model:0": { - APIKeys: []string{"key1"}, - }, - "lb-model:1": { - APIKeys: []string{"key2"}, - }, - "lb-model:2": { - APIKeys: []string{"key3"}, - }, - }, - }) + } // Test round-robin distribution results := make(map[string]int) @@ -111,9 +86,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { cfg := &Config{ ModelList: []*ModelConfig{ - {ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}}, - {ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}}, - {ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}}, + {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, + {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")}, }, } @@ -139,8 +114,8 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) { func TestGetModelConfig_Concurrent(t *testing.T) { cfg := &Config{ ModelList: []*ModelConfig{ - {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}}, - {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}}, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")}, + {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")}, }, } diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go index c17fcc53b..e58c6dc9e 100644 --- a/pkg/config/multikey_test.go +++ b/pkg/config/multikey_test.go @@ -9,7 +9,7 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"single-key"}, + APIKeys: SimpleSecureStrings("single-key"), }, } @@ -38,7 +38,7 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) { ModelName: "glm-4.7", Model: "zhipu/glm-4.7", APIBase: "https://api.example.com", - apiKeys: []string{"key1", "key2", "key3"}, + APIKeys: SimpleSecureStrings("key1", "key2", "key3"), }, } @@ -91,7 +91,7 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"key0", "key1", "key2"}, + APIKeys: SimpleSecureStrings("key0", "key1", "key2"), }, } @@ -117,7 +117,7 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) { ModelName: "gpt-4", Model: "openai/gpt-4o", } - modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing modelCfg.Fallbacks = []string{"claude-3"} models := []*ModelConfig{modelCfg} @@ -143,7 +143,7 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{}, + APIKeys: SimpleSecureStrings(), }, } @@ -164,7 +164,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"key1", "key2", "key1"}, // Duplicate key1 + APIKeys: SimpleSecureStrings("key1", "key2", "key1"), // Duplicate key1 }, } @@ -196,7 +196,7 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) { RequestTimeout: 30, ThinkingLevel: "high", } - modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing + modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing models := []*ModelConfig{modelCfg} result := expandMultiKeyModels(models) @@ -237,7 +237,7 @@ func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"key1", "key2", "key3"}, + APIKeys: SimpleSecureStrings("key1", "key2", "key3"), }, } @@ -288,7 +288,7 @@ func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) { { ModelName: "gpt-4", Model: "openai/gpt-4o", - apiKeys: []string{"single-key"}, + APIKeys: SimpleSecureStrings("single-key"), }, } diff --git a/pkg/config/security.go b/pkg/config/security.go index 47ad1a5b0..79dd26e14 100644 --- a/pkg/config/security.go +++ b/pkg/config/security.go @@ -7,182 +7,26 @@ package config import ( "bytes" + "encoding/json" "fmt" "os" "path/filepath" "reflect" + "runtime" "strings" "sync" - "github.com/caarlos0/env/v11" - "github.com/tencent-connect/botgo/log" "gopkg.in/yaml.v3" + "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" ) const ( SecurityConfigFile = ".security.yml" ) -func normalizeSecurityConfig(sec *SecurityConfig) *SecurityConfig { - if sec == nil { - sec = &SecurityConfig{} - } - if sec.ModelList == nil { - sec.ModelList = map[string]ModelSecurityEntry{} - } - if sec.Channels == nil { - sec.Channels = &ChannelsSecurity{} - } - if sec.Web == nil { - sec.Web = &WebToolsSecurity{} - } - if sec.Skills == nil { - sec.Skills = &SkillsSecurity{} - } - return sec -} - -// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords) -// This data is loaded from security.yml and kept separate from the main config -type SecurityConfig struct { - // Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1" - // for load balancing with same model_name. The suffix ":N" is used to distinguish - // multiple configs that share the same base model_name. - ModelList map[string]ModelSecurityEntry `yaml:"model_list"` - - // Channel tokens/secrets - Channels *ChannelsSecurity `yaml:"channels,omitempty"` - - Web *WebToolsSecurity `yaml:"web,omitempty"` - Skills *SkillsSecurity `yaml:"skills,omitempty"` - - // cache for sensitive values and compiled regex (computed once) - sensitiveCache *SensitiveDataCache -} - -// ModelSecurityEntry stores security data for a model -type ModelSecurityEntry struct { - APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover) -} - -// ChannelsSecurity stores channel-related security data -type ChannelsSecurity struct { - Telegram *TelegramSecurity `yaml:"telegram,omitempty"` - Feishu *FeishuSecurity `yaml:"feishu,omitempty"` - Discord *DiscordSecurity `yaml:"discord,omitempty"` - Weixin *WeixinSecurity `yaml:"weixin,omitempty"` - QQ *QQSecurity `yaml:"qq,omitempty"` - DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"` - Slack *SlackSecurity `yaml:"slack,omitempty"` - Matrix *MatrixSecurity `yaml:"matrix,omitempty"` - LINE *LINESecurity `yaml:"line,omitempty"` - OneBot *OneBotSecurity `yaml:"onebot,omitempty"` - WeCom *WeComSecurity `yaml:"wecom,omitempty"` - Pico *PicoSecurity `yaml:"pico,omitempty"` - IRC *IRCSecurity `yaml:"irc,omitempty"` -} - -type TelegramSecurity struct { - Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` -} - -type FeishuSecurity struct { - AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` -} - -type DiscordSecurity struct { - Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` -} - -type WeixinSecurity struct { - Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"` -} - -type QQSecurity struct { - AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` -} - -type DingTalkSecurity struct { - ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` -} - -type SlackSecurity struct { - BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` -} - -type MatrixSecurity struct { - AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` -} - -type LINESecurity struct { - ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` -} - -type OneBotSecurity struct { - AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` -} - -type WeComSecurity struct { - Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_SECRET"` -} - -type PicoSecurity struct { - Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` -} - -type IRCSecurity struct { - Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"` - NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"` - SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"` -} - -type WebToolsSecurity struct { - Brave *BraveSecurity `yaml:"brave,omitempty"` - Tavily *TavilySecurity `yaml:"tavily,omitempty"` - Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"` - GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"` - BaiduSearch *BaiduSearchSecurity `yaml:"baidu_search,omitempty"` -} - -type BraveSecurity struct { - APIKeys []string `yaml:"api_keys,omitempty"` -} - -type TavilySecurity struct { - APIKeys []string `yaml:"api_keys,omitempty"` -} - -type PerplexitySecurity struct { - APIKeys []string `yaml:"api_keys,omitempty"` -} - -type GLMSearchSecurity struct { - APIKey string `yaml:"api_key,omitempty"` -} - -type BaiduSearchSecurity struct { - APIKey string `yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"` -} - -type SkillsSecurity struct { - Github *GithubSecurity `yaml:"github,omitempty"` - ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"` -} - -type GithubSecurity struct { - Token string `yaml:"token,omitempty"` -} - -type ClawHubSecurity struct { - AuthToken string `yaml:"auth_token,omitempty"` -} - // securityPath returns the path to security.yml relative to the config file func securityPath(configPath string) string { configDir := filepath.Dir(configPath) @@ -191,34 +35,27 @@ func securityPath(configPath string) string { // loadSecurityConfig loads the security configuration from security.yml // Returns an empty SecurityConfig if the file doesn't exist -func loadSecurityConfig(securityPath string) (*SecurityConfig, error) { +func loadSecurityConfig(cfg *Config, securityPath string) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } data, err := os.ReadFile(securityPath) if err != nil { if os.IsNotExist(err) { - return normalizeSecurityConfig(nil), nil + return nil } - return nil, fmt.Errorf("failed to read security config: %w", err) + return fmt.Errorf("failed to read security config: %w", err) } - var sec SecurityConfig - if err := yaml.Unmarshal(data, &sec); err != nil { - return nil, fmt.Errorf("failed to parse security config: %w", err) + if err := yaml.Unmarshal(data, cfg); err != nil { + return fmt.Errorf("failed to parse security config: %w", err) } - // No need to validate model_name format here - both formats are supported: - // - "model-name:0" (with index for multiple entries) - // - "model-name" (without index for single entry or default to index 0) - - if err := env.Parse(&sec); err != nil { - log.Errorf("failed to parse environment variables: %v", err) - return nil, err - } - - return normalizeSecurityConfig(&sec), nil + return nil } // saveSecurityConfig saves the security configuration to security.yml -func saveSecurityConfig(securityPath string, sec *SecurityConfig) error { +func saveSecurityConfig(securityPath string, sec *Config) error { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) @@ -229,134 +66,6 @@ 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.Secret != "" { - dst.WeCom = src.WeCom - } - 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. @@ -367,13 +76,13 @@ type SensitiveDataCache struct { // SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data. // It is computed once on first access via sync.Once. -func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer { +func (sec *Config) SensitiveDataReplacer() *strings.Replacer { sec.initSensitiveCache() return sec.sensitiveCache.replacer } // initSensitiveCache initializes the sensitive data cache if not already done. -func (sec *SecurityConfig) initSensitiveCache() { +func (sec *Config) initSensitiveCache() { if sec.sensitiveCache == nil { sec.sensitiveCache = &SensitiveDataCache{} } @@ -400,15 +109,14 @@ func (sec *SecurityConfig) initSensitiveCache() { } // collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection. -func (sec *SecurityConfig) collectSensitiveValues() []string { +func (sec *Config) collectSensitiveValues() []string { var values []string collectSensitive(reflect.ValueOf(sec), &values) return values } -// collectSensitive recursively traverses the value and collects all non-empty string fields. +// collectSensitive recursively traverses the value and collects SecureString/SecureStrings values. func collectSensitive(v reflect.Value, values *[]string) { - // Dereference pointers/interfaces to get the underlying value for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { if v.IsNil() { return @@ -416,27 +124,53 @@ func collectSensitive(v reflect.Value, values *[]string) { v = v.Elem() } + t := v.Type() + + // SecureString: collect via String() method (defined on *SecureString) + if t == reflect.TypeOf(SecureString{}) { + result := v.Addr().MethodByName("String").Call(nil) + if len(result) > 0 { + if s := result[0].String(); s != "" { + *values = append(*values, s) + } + } + return + } + + // SecureStrings ([]*SecureString): iterate and collect each element + if t == reflect.TypeOf(SecureStrings{}) { + for i := 0; i < v.Len(); i++ { + elem := v.Index(i) + for elem.Kind() == reflect.Ptr || elem.Kind() == reflect.Interface { + if elem.IsNil() { + elem = reflect.Value{} + break + } + elem = elem.Elem() + } + if elem.IsValid() && elem.Type() == reflect.TypeOf(SecureString{}) { + result := elem.Addr().MethodByName("String").Call(nil) + if len(result) > 0 { + if s := result[0].String(); s != "" { + *values = append(*values, s) + } + } + } + } + return + } + switch v.Kind() { case reflect.Struct: for i := 0; i < v.NumField(); i++ { - field := v.Field(i) - fieldType := v.Type().Field(i) - if !fieldType.IsExported() { + if !t.Field(i).IsExported() { continue } - collectSensitive(field, values) - } - case reflect.String: - if v.String() != "" { - *values = append(*values, v.String()) + collectSensitive(v.Field(i), values) } case reflect.Slice: - if v.Type().Elem().Kind() == reflect.String { - for i := 0; i < v.Len(); i++ { - if s := v.Index(i).String(); s != "" { - *values = append(*values, s) - } - } + for i := 0; i < v.Len(); i++ { + collectSensitive(v.Index(i), values) } case reflect.Map: for _, key := range v.MapKeys() { @@ -444,3 +178,234 @@ func collectSensitive(v reflect.Value, values *[]string) { } } } + +const ( + notHere = `"[NOT_HERE]"` +) + +// SecureStrings is a slice of SecureString +type SecureStrings []*SecureString + +// Values returns the decrypted/resolved values +func (s *SecureStrings) Values() []string { + if s == nil { + return nil + } + keys := make([]string, len(*s)) + for i, k := range *s { + keys[i] = k.String() + } + return unique(keys) +} + +func SimpleSecureStrings(val ...string) SecureStrings { + val = unique(val) + vv := make(SecureStrings, len(val)) + for i, s := range val { + vv[i] = NewSecureString(s) + } + return vv +} + +// unique returns a new slice with duplicate elements removed. +func unique[T comparable](input []T) []T { + m := make(map[T]struct{}) + var result []T + for _, v := range input { + if _, ok := m[v]; !ok { + m[v] = struct{}{} + result = append(result, v) + } + } + return result +} + +func (s SecureStrings) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureStrings) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v []*SecureString + err := json.Unmarshal(value, &v) + if err != nil { + return err + } + *s = v + return nil +} + +// SecureString the string value that can be decrypted or resolved +// +//nolint:recvcheck +type SecureString struct { + resolved string // Decrypted/resolved value returned by String() + raw string // Persisted raw value (enc://, file://, or plaintext) +} + +func callerFromYaml() bool { + _, file, _, ok := runtime.Caller(2) + if ok { + d := filepath.Dir(file) + // check the caller is from yaml.v + if !strings.Contains(d, "yaml.v") { + return true + } + } + return false +} + +// IsZero returns true if the SecureString is empty +// if caller not yaml, just return true for prevent marshal this field +func (s SecureString) IsZero() bool { + if callerFromYaml() { + return true + } + return s.resolved == "" +} + +func NewSecureString(value string) *SecureString { + s := &SecureString{} + if err := s.fromRaw(value); err != nil { + logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err)) + } + return s +} + +func (s *SecureString) String() string { + if s == nil { + return "" + } + return s.resolved +} + +func (s *SecureString) Set(value string) *SecureString { + s.resolved = value + s.raw = "" + return s +} + +func (s SecureString) MarshalJSON() ([]byte, error) { + return []byte(notHere), nil +} + +func (s *SecureString) UnmarshalJSON(value []byte) error { + if string(value) == notHere { + return nil + } + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + return s.fromRaw(v) +} + +func (s SecureString) MarshalYAML() (any, error) { + // Preserve raw value if it is already a reference (enc:// or file://) + if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) { + return s.raw, nil + } + // If resolved is a reference format (e.g. set via Set), copy back to raw + if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) { + s.raw = s.resolved + return s.raw, nil + } + // Try to encrypt the resolved value + if passphrase := credential.PassphraseProvider(); passphrase != "" { + encrypted, err := credential.Encrypt(passphrase, "", s.resolved) + if err != nil { + logger.Errorf("Encrypt error: %v", err) + return nil, err + } + s.raw = encrypted + } else { + s.raw = s.resolved + } + return s.raw, nil +} + +func (s *SecureString) UnmarshalYAML(value *yaml.Node) error { + return s.fromRaw(value.Value) +} + +func (s *SecureString) fromRaw(v string) error { + s.raw = v + vv, err := resolveKey(v) + if err != nil { + return err + } + s.resolved = vv + return nil +} + +var ( + secResolverMu sync.RWMutex + secResolver *credential.Resolver +) + +func updateResolver(path string) { + secResolverMu.Lock() + defer secResolverMu.Unlock() + secResolver = credential.NewResolver(path) +} + +func resolveKey(v string) (string, error) { + secResolverMu.RLock() + resolver := secResolver + secResolverMu.RUnlock() + if resolver == nil { + resolver = credential.NewResolver("") + } + if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") { + decrypted, err := resolver.Resolve(v) + if err != nil { + logger.Errorf("Resolve error: %v", err) + return "", err + } + return decrypted, nil + } + return v, nil +} + +func (s *SecureString) UnmarshalText(text []byte) error { + v := string(text) + return s.fromRaw(v) +} + +type SecureModelList []*ModelConfig + +func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error { + mm := make(map[string]*ModelConfig) + if err := value.Decode(&mm); err != nil { + logger.Errorf("Decode error: %v", err) + return err + } + nameList := toNameIndex(*v) + for i, m := range *v { + sec := mm[nameList[i]] + if sec == nil { + sec = mm[m.ModelName] + } + if sec != nil { + m.APIKeys = sec.APIKeys + } + } + return nil +} + +func (v SecureModelList) MarshalYAML() (any, error) { + type onlySecureData struct { + APIKeys SecureStrings `yaml:"api_keys,omitempty"` + } + mm := make(map[string]onlySecureData) + nameList := toNameIndex(v) + for i, m := range v { + mm[nameList[i]] = onlySecureData{ + APIKeys: m.APIKeys, + } + } + + return mm, nil +} diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 002988f2f..24170f84b 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -66,7 +66,7 @@ func TestSecurityConfigIntegration(t *testing.T) { "web": { "brave": { "enabled": true, - "api_key": "BSA-from-config-json-direct" + "api_keys": ["BSA-from-config-json-direct"] } }, "skills": { @@ -91,11 +91,6 @@ channels: telegram: token: "token-from-security-yml" -web: - brave: - api_keys: - - "BSA-from-security-yml" - skills: github: token: "ghp-from-security-yml"` @@ -110,16 +105,18 @@ skills: // Verify model API key from config.json takes precedence assert.Equal(t, 1, len(cfg.ModelList)) assert.Equal(t, "test-model", cfg.ModelList[0].ModelName) - assert.Equal(t, "sk-from-config-json-direct", cfg.ModelList[0].apiKeys[0]) + assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKey()) // Verify channel token from config.json takes precedence - assert.Equal(t, "token-from-config-json-direct", cfg.Channels.Telegram.token) + assert.Equal(t, "token-from-security-yml", cfg.Channels.Telegram.Token.String()) + + assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKeys[0].String()) // Verify web tool API key from config.json takes precedence assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey()) - // Verify skills token from config.json takes precedence - assert.Equal(t, "ghp-from-config-json-direct", cfg.Tools.Skills.Github.token) + // Verify skills token is resolved + assert.Equal(t, "ghp-from-security-yml", cfg.Tools.Skills.Github.Token.String()) }) } @@ -355,66 +352,66 @@ skills: // Verify Channel tokens via Key() methods // Telegram - assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token()) - t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token()) + assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String()) + t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String()) // Feishu - assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret()) - assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey()) - assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken()) - t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret()) - t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey()) - t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken()) + assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String()) + assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String()) + assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String()) + t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String()) + t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String()) + t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String()) // Discord - assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token()) - t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token()) + assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token.String()) + t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token.String()) // DingTalk - assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret()) - t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret()) + assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret.String()) + t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret.String()) // Slack - assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken()) - assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken()) - t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken()) - t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken()) + assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken.String()) + assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken.String()) + t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken.String()) + t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken.String()) // Matrix - assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken()) - t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken()) + assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken.String()) + t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken.String()) // LINE - assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret()) - assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken()) - t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret()) - t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken()) + assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String()) + assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String()) + t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String()) + t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String()) // OneBot - assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken()) - t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken()) + assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken.String()) + t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken.String()) // WeCom assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID) - assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret()) + assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret.String()) t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID) - t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret()) + t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret.String()) // Pico - assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token()) - t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token()) + assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token.String()) + t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token.String()) // IRC - assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password()) - assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword()) - assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword()) - t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password()) - t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword()) - t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword()) + assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password.String()) + assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword.String()) + assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword.String()) + t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password.String()) + t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword.String()) + t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword.String()) // QQ - assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret()) - t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret()) + assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret.String()) + t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret.String()) // Verify Web tool API keys assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey()) @@ -427,15 +424,15 @@ skills: t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey()) // GLM Search - Note: GLM uses SetAPIKey (lowercase) internally - t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey()) - assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey()) + t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String()) + assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String()) // Verify Skills tokens - assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token()) - t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token()) + assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token.String()) + t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token.String()) - assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken()) - t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken()) + assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) + t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String()) t.Log("All security keys are successfully accessible via their respective Key() methods") }) diff --git a/pkg/config/security_test.go b/pkg/config/security_test.go index 0f260ed59..834ba3606 100644 --- a/pkg/config/security_test.go +++ b/pkg/config/security_test.go @@ -6,23 +6,29 @@ package config import ( + "encoding/json" "os" "path/filepath" "testing" + "github.com/caarlos0/env/v11" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/credential" ) func TestSecurityConfig(t *testing.T) { t.Run("LoadNonExistent", func(t *testing.T) { - sec, err := loadSecurityConfig("/nonexistent/.security.yml") + sec := &Config{} + err := loadSecurityConfig(sec, "/nonexistent/.security.yml") require.NoError(t, err) assert.NotNil(t, sec) assert.Empty(t, sec.ModelList) assert.NotNil(t, sec.Channels) - assert.NotNil(t, sec.Web) - assert.NotNil(t, sec.Skills) + assert.NotNil(t, sec.Tools.Web) + assert.NotNil(t, sec.Tools.Skills) }) } @@ -53,41 +59,302 @@ func TestSecurityPath(t *testing.T) { } func TestSaveAndLoadSecurityConfig(t *testing.T) { + t.Run("test for securestring", func(t *testing.T) { + type testStruct struct { + Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"TEST_SECURE_STRING"` + } + s := testStruct{Secret: *NewSecureString("test")} + out, err := yaml.Marshal(s) // 直接对 SecureString 进行序列化 + require.NoError(t, err) + t.Logf("output: %v", string(out)) + assert.Equal(t, "secret: test\n", string(out)) + out, err = json.Marshal(s) + require.NoError(t, err) + t.Logf("output: %v", string(out)) + assert.Equal(t, "{}", string(out)) + }) tmpDir := t.TempDir() secPath := filepath.Join(tmpDir, SecurityConfigFile) - original := &SecurityConfig{ - ModelList: map[string]ModelSecurityEntry{ - "model1:0": { - APIKeys: []string{"key1", "key2"}, + original := &Config{ + ModelList: SecureModelList{ + { + ModelName: "model1", + Model: "test/model", + APIBase: "api.example.com", + APIKeys: SecureStrings{NewSecureString("key1"), NewSecureString("key2")}, + }, + { + ModelName: "model2", + Model: "test/model2", + APIBase: "api2.example.com", + APIKeys: SecureStrings{NewSecureString("model2_key")}, }, }, - Channels: &ChannelsSecurity{ - Telegram: &TelegramSecurity{ - Token: "telegram-token", + Tools: ToolsConfig{ + Web: WebToolsConfig{ + Brave: BraveConfig{ + Enabled: true, + APIKeys: SecureStrings{NewSecureString("brave_key")}, + }, + }, + Skills: SkillsToolsConfig{ + Github: SkillsGithubConfig{ + Token: *NewSecureString("github_token"), + Proxy: "test proxy", + }, }, }, - Web: &WebToolsSecurity{ - Brave: &BraveSecurity{ - APIKeys: []string{"brave-api-key"}, + Channels: ChannelsConfig{ + Telegram: TelegramConfig{ + Enabled: true, + Token: *NewSecureString("telegram_token"), + }, + Feishu: FeishuConfig{ + Enabled: true, + AppID: "feishu_app_id", + AppSecret: *NewSecureString("feishu_app_secret"), + }, + Discord: DiscordConfig{ + Enabled: true, + Token: *NewSecureString("discord_token"), + }, + QQ: QQConfig{ + Enabled: true, + AppSecret: *NewSecureString("qq_app_secret"), + }, + PicoClient: PicoClientConfig{ + Enabled: true, + Token: *NewSecureString("pico_client_token"), }, }, } - // Save - err := saveSecurityConfig(secPath, original) - require.NoError(t, err) + t.Run("test for original", func(t *testing.T) { + assert.Equal(t, 2, len(original.ModelList[0].APIKeys)) + assert.Equal(t, "key1", original.ModelList[0].APIKeys[0].String()) + }) - // Verify file was created with correct permissions - info, err := os.Stat(secPath) - require.NoError(t, err) - assert.Equal(t, os.FileMode(0o600), info.Mode()) + cfg2 := &Config{} + t.Run("test for json", func(t *testing.T) { + marshal, err := json.Marshal(original) + require.NoError(t, err) + t.Logf("json: %s", string(marshal)) + assert.Contains(t, string(marshal), "\"api_keys\"") + assert.Contains(t, string(marshal), notHere) - // Load - loaded, err := loadSecurityConfig(secPath) - require.NoError(t, err) + err = json.Unmarshal(marshal, cfg2) + require.NoError(t, err) + require.Equal(t, 2, len(cfg2.ModelList)) + assert.Empty(t, cfg2.ModelList[0].APIKeys) + assert.Empty(t, cfg2.ModelList[1].APIKeys) + }) - assert.Equal(t, original.ModelList, loaded.ModelList) - assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token) - assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys) + t.Run("test for save yaml", func(t *testing.T) { + // Save + err := saveSecurityConfig(secPath, original) + require.NoError(t, err) + + // Verify file was created with correct permissions + info, err := os.Stat(secPath) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode()) + + file, err := os.ReadFile(secPath) + assert.NoError(t, err) + t.Logf("%s", string(file)) + yamlOutput := `channels: + telegram: + token: telegram_token + feishu: + app_secret: feishu_app_secret + discord: + token: discord_token + qq: + app_secret: qq_app_secret + pico_client: + token: pico_client_token +model_list: + model1:0: + api_keys: + - key1 + - key2 + model2:0: + api_keys: + - model2_key +web: + brave: + api_keys: + - brave_key +skills: + github: + token: github_token +` + assert.Equal(t, yamlOutput, string(file)) + + err = os.WriteFile(secPath, []byte(yamlOutput), 0o600) + require.NoError(t, err) + }) + + t.Run("test for load yaml", func(t *testing.T) { + // Load + cfg := cfg2 + err := loadSecurityConfig(cfg, secPath) + require.NoError(t, err) + + t.Logf("%+v", cfg) + t.Logf("%+v", cfg.Tools.Web.Brave.APIKeys) + t.Logf("%+v", cfg.Tools.Skills.Github.Token) + require.EqualValues(t, 2, len(cfg.ModelList)) + assert.Equal(t, "key1", cfg.ModelList[0].APIKeys[0].String()) + assert.Equal(t, "key2", cfg.ModelList[0].APIKeys[1].String()) + assert.Equal(t, "model2_key", cfg.ModelList[1].APIKeys[0].String()) + assert.EqualValues(t, original.Tools.Web.Brave.APIKeys, cfg.Tools.Web.Brave.APIKeys) + }) + + t.Run("test for env overwrite", func(t *testing.T) { + // This will throw a COMPILER ERROR if SecureString doesn't + // correctly implement the yaml.Marshaler interface. + var _ yaml.Marshaler = (*SecureString)(nil) + // If you are using Value types in your config, also check: + var _ yaml.Marshaler = SecureString{} + t.Setenv("PICOCLAW_CHANNELS_QQ_APP_SECRET", "qq_app_secret_env") + t.Setenv("PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS", "brave_key_env,abc") + err2 := env.Parse(cfg2) + require.NoError(t, err2) + assert.Equal(t, "qq_app_secret_env", cfg2.Channels.QQ.AppSecret.raw) + assert.Equal(t, "brave_key_env", cfg2.Tools.Web.Brave.APIKeys[0].raw) + assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw) + }) +} + +func TestLoadSecurityValue(t *testing.T) { + type valueStruct struct { + Url string `json:"url,omitempty" yaml:"-"` + Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"` + ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"` + } + + type testStruct struct { + Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + v1 := &testStruct{ + Pico: &valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + } + bytes, err := yaml.Marshal(v1) + assert.NoError(t, err) + jsonBytes, err := json.Marshal(v1) + assert.NoError(t, err) + const want = `pico: + token: token1 + api_keys: + - api-key1 + - api-key2 +` + const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}` + v0 := &testStruct{} + err = json.Unmarshal([]byte(jsonPost), v0) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v0.Pico.Url) + assert.Equal(t, "token0", v0.Pico.Token.String()) + + const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}` + assert.Equal(t, want, string(bytes)) + assert.Equal(t, jsonWant, string(jsonBytes)) + + v2 := &testStruct{} + err = json.Unmarshal(jsonBytes, v2) + assert.NoError(t, err) + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v2.Pico.Url) + if v2.Pico.Token != nil { + assert.Equal(t, "token1", v2.Pico.Token.String()) + assert.Equal(t, "token1", v2.Pico.Token.raw) + } + + v2.Pico.Token = NewSecureString("token1") + v2.Pico.Token.raw = "abc" + err = yaml.Unmarshal(bytes, v2) + assert.NoError(t, err) + assert.Equal(t, "token1", v2.Pico.Token.raw) + + os.Setenv("PICO_TOKEN", "token_env") + err = env.Parse(v2) + assert.NoError(t, err) + assert.NotNil(t, v2.Pico.Token) + assert.Equal(t, "token1", v2.Pico.Token.String()) + + v3 := &testStruct{Pico: &valueStruct{}} + err = env.Parse(v3) + assert.NoError(t, err) + if v3.Pico.Token != nil { + assert.Equal(t, "token_env", v3.Pico.Token.String()) + } + + type toolsStruct struct { + Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"` + } + + type testStruct2 struct { + Tools toolsStruct `json:"tools,omitempty" yaml:",inline"` + } + + v4 := &testStruct2{ + Tools: toolsStruct{ + Pico: valueStruct{ + Url: "https://example.com", + Token: NewSecureString("token1"), + ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")}, + }, + }, + } + bytes, err = yaml.Marshal(v4) + assert.NoError(t, err) + assert.Equal(t, want, string(bytes)) + jsonBytes, err = json.Marshal(v4) + assert.NoError(t, err) + assert.Equal( + t, + `{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`, + string(jsonBytes), + ) + + v5 := &testStruct2{} + err = json.Unmarshal(jsonBytes, v5) + assert.NoError(t, err) + assert.Equal(t, "https://example.com", v5.Tools.Pico.Url) + err = yaml.Unmarshal(bytes, v5) + assert.NoError(t, err) + assert.NotNil(t, v5.Tools.Pico.Token) + assert.Equal(t, "token1", v5.Tools.Pico.Token.raw) + + dir := t.TempDir() + sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key") + if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil { + t.Fatalf("setup: %v", err) + } + + const passphrase = "test-passphrase-32bytes-long-ok!" + + t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath) + + t.Setenv(credential.PassphraseEnvVar, passphrase) + + v5.Tools.Pico.Token.Set("newtoken1") + v5.Tools.Pico.ApiKeys[0].Set("newapi-key1") + bytes, err = yaml.Marshal(v5) + assert.NoError(t, err) + t.Logf("yaml: %s", string(bytes)) + + v6 := &testStruct2{} + err = yaml.Unmarshal(bytes, v6) + assert.NoError(t, err) + assert.NotNil(t, v6.Tools.Pico.Token) + assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String()) } diff --git a/pkg/credential/credential.go b/pkg/credential/credential.go index b65c19446..8ecd6783b 100644 --- a/pkg/credential/credential.go +++ b/pkg/credential/credential.go @@ -75,12 +75,13 @@ const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH" const picoclawHome = "PICOCLAW_HOME" const ( - fileScheme = "file://" - encScheme = "enc://" - hkdfInfo = "picoclaw-credential-v1" - saltLen = 16 - nonceLen = 12 - keyLen = 32 + FileScheme = "file://" + EncScheme = "enc://" + + hkdfInfo = "picoclaw-credential-v1" + saltLen = 16 + nonceLen = 12 + keyLen = 32 ) // Resolver resolves raw credential strings for model_list api_key fields. @@ -112,8 +113,8 @@ func (r *Resolver) Resolve(raw string) (string, error) { return "", nil } - if strings.HasPrefix(raw, fileScheme) { - fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme)) + if strings.HasPrefix(raw, FileScheme) { + fileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme)) if fileName == "" { return "", fmt.Errorf("credential: file:// reference has no filename") } @@ -144,7 +145,7 @@ func (r *Resolver) Resolve(raw string) (string, error) { return value, nil } - if strings.HasPrefix(raw, encScheme) { + if strings.HasPrefix(raw, EncScheme) { return resolveEncrypted(raw) } @@ -161,7 +162,7 @@ func resolveEncrypted(raw string) (string, error) { sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect - b64 := strings.TrimPrefix(raw, encScheme) + b64 := strings.TrimPrefix(raw, EncScheme) blob, err := base64.StdEncoding.DecodeString(b64) if err != nil { return "", fmt.Errorf("credential: enc:// invalid base64: %w", err) @@ -234,7 +235,7 @@ func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { blob = append(blob, salt...) blob = append(blob, nonce...) blob = append(blob, ciphertext...) - return encScheme + base64.StdEncoding.EncodeToString(blob), nil + return EncScheme + base64.StdEncoding.EncodeToString(blob), nil } // isWithinDir reports whether path is contained within (or equal to) dir. diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index b56194b3d..4436c1861 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -1029,7 +1029,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { Proxy: c.Telegram.Proxy, } if c.Telegram.Token != "" { - tc.SetToken(c.Telegram.Token) + tc.Token = *config.NewSecureString(c.Telegram.Token) } return tc }(), @@ -1039,13 +1039,13 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { AppID: c.Feishu.AppID, } if c.Feishu.AppSecret != "" { - fc.SetAppSecret(c.Feishu.AppSecret) + fc.AppSecret = *config.NewSecureString(c.Feishu.AppSecret) } if c.Feishu.EncryptKey != "" { - fc.SetEncryptKey(c.Feishu.EncryptKey) + fc.EncryptKey = *config.NewSecureString(c.Feishu.EncryptKey) } if c.Feishu.VerificationToken != "" { - fc.SetVerificationToken(c.Feishu.VerificationToken) + fc.VerificationToken = *config.NewSecureString(c.Feishu.VerificationToken) } return fc }(), @@ -1055,7 +1055,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { MentionOnly: c.Discord.MentionOnly, } if c.Discord.Token != "" { - dc.SetToken(c.Discord.Token) + dc.Token = *config.NewSecureString(c.Discord.Token) } return dc }(), @@ -1070,7 +1070,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { AppID: c.QQ.AppID, } if c.QQ.AppSecret != "" { - qc.SetAppSecret(c.QQ.AppSecret) + qc.AppSecret = *config.NewSecureString(c.QQ.AppSecret) } return qc }(), @@ -1080,7 +1080,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { ClientID: c.DingTalk.ClientID, } if c.DingTalk.ClientSecret != "" { - dt.SetClientSecret(c.DingTalk.ClientSecret) + dt.ClientSecret = *config.NewSecureString(c.DingTalk.ClientSecret) } return dt }(), @@ -1089,10 +1089,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { Enabled: c.Slack.Enabled, } if c.Slack.BotToken != "" { - sc.SetBotToken(c.Slack.BotToken) + sc.BotToken = *config.NewSecureString(c.Slack.BotToken) } if c.Slack.AppToken != "" { - sc.SetAppToken(c.Slack.AppToken) + sc.AppToken = *config.NewSecureString(c.Slack.AppToken) } return sc }(), @@ -1105,7 +1105,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { JoinOnInvite: true, } if c.Matrix.AccessToken != "" { - mc.SetAccessToken(c.Matrix.AccessToken) + mc.AccessToken = *config.NewSecureString(c.Matrix.AccessToken) } return mc }(), @@ -1117,10 +1117,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { WebhookPath: c.LINE.WebhookPath, } if c.LINE.ChannelSecret != "" { - lc.SetChannelSecret(c.LINE.ChannelSecret) + lc.ChannelSecret = *config.NewSecureString(c.LINE.ChannelSecret) } if c.LINE.ChannelAccessToken != "" { - lc.SetChannelAccessToken(c.LINE.ChannelAccessToken) + lc.ChannelAccessToken = *config.NewSecureString(c.LINE.ChannelAccessToken) } return lc }(), diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 350b29776..7fe112223 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) { if !stdCfg.Channels.Telegram.Enabled { t.Error("telegram should be enabled") } - if stdCfg.Channels.Telegram.Token() != "test-token" { - t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token()) + if stdCfg.Channels.Telegram.Token.String() != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token.String()) } if stdCfg.Gateway.Port != 8080 { diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index 70a7fca8f..3e71ff13a 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -20,89 +20,72 @@ func TestDetectTranscriber(t *testing.T) { }, { name: "voice model name selects audio model transcriber", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ModelName: "voice-gemini"}, ModelList: []*config.ModelConfig{ - {ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "voice-gemini": { - APIKeys: []string{"sk-gemini-model"}, + { + ModelName: "voice-gemini", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-gemini-model"), }, }, - }), + }, wantName: "audio-model", }, { name: "groq via model list", - cfg: (&config.Config{ + cfg: &config.Config{ ModelList: []*config.ModelConfig{ - {ModelName: "openai", Model: "openai/gpt-4o"}, - {ModelName: "groq", Model: "groq/llama-3.3-70b"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "openai": { - APIKeys: []string{"sk-openai"}, - }, - "groq": { - APIKeys: []string{"sk-groq-model"}, + {ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")}, + { + ModelName: "groq", + Model: "groq/llama-3.3-70b", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, - }), + }, wantName: "groq", }, { name: "voice model name selects non-gemini audio model transcriber", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ModelName: "voice-openai-audio"}, ModelList: []*config.ModelConfig{ - {ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "voice-openai-audio": { - APIKeys: []string{"sk-openai"}, + { + ModelName: "voice-openai-audio", + Model: "openai/gpt-4o-audio-preview", + APIKeys: config.SimpleSecureStrings("sk-openai"), }, }, - }), + }, wantName: "audio-model", }, { name: "voice model name selects azure audio model transcriber", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ModelName: "voice-azure-audio"}, ModelList: []*config.ModelConfig{ { ModelName: "voice-azure-audio", - Model: "azure/my-audio-deployment", - APIBase: "https://example.openai.azure.com", + Model: "azure/my-audio-deployment", APIKeys: config.SimpleSecureStrings("sk-azure"), + APIBase: "https://example.openai.azure.com", }, }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "voice-azure-audio": { - APIKeys: []string{"sk-azure"}, - }, - }, - }), + }, wantName: "audio-model", }, { name: "voice model name with non openai compatible protocol does not select audio model transcriber", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ModelName: "voice-anthropic"}, ModelList: []*config.ModelConfig{ - {ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "voice-anthropic": { - APIKeys: []string{"sk-anthropic"}, + { + ModelName: "voice-anthropic", + Model: "anthropic/claude-sonnet-4.6", + APIKeys: config.SimpleSecureStrings("sk-anthropic"), }, }, - }), + }, wantNil: true, }, { @@ -116,33 +99,29 @@ func TestDetectTranscriber(t *testing.T) { }, { name: "provider key takes priority over model list", - cfg: (&config.Config{ + cfg: &config.Config{ ModelList: []*config.ModelConfig{ - {ModelName: "groq", Model: "groq/llama-3.3-70b"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "groq": { - APIKeys: []string{"sk-groq-model"}, + { + ModelName: "groq", + Model: "groq/llama-3.3-70b", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, - }), + }, wantName: "groq", }, { name: "missing voice model name config returns nil", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ModelName: "missing"}, ModelList: []*config.ModelConfig{ - {ModelName: "other", Model: "gemini/gemini-2.5-flash"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "other": { - APIKeys: []string{"sk-other-model"}, + { + ModelName: "other", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-other-model"), }, }, - }), + }, wantNil: true, }, { @@ -154,37 +133,33 @@ func TestDetectTranscriber(t *testing.T) { }, { name: "elevenlabs takes priority over groq model list", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"}, ModelList: []*config.ModelConfig{ - {ModelName: "groq", Model: "groq/llama-3.3-70b"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "groq": { - APIKeys: []string{"sk-groq-direct"}, + { + ModelName: "groq", + Model: "groq/llama-3.3-70b", + APIKeys: config.SimpleSecureStrings("sk-groq-model"), }, }, - }), + }, wantName: "elevenlabs", }, { name: "voice model name takes priority over elevenlabs", - cfg: (&config.Config{ + cfg: &config.Config{ Voice: config.VoiceConfig{ ModelName: "voice-gemini", ElevenLabsAPIKey: "sk_elevenlabs_test", }, ModelList: []*config.ModelConfig{ - {ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"}, - }, - }).WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "voice-gemini": { - APIKeys: []string{"sk-gemini-model"}, + { + ModelName: "voice-gemini", + Model: "gemini/gemini-2.5-flash", + APIKeys: config.SimpleSecureStrings("sk-gemini-model"), }, }, - }), + }, wantName: "audio-model", }, } diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 618b8438d..0add7594d 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -58,12 +58,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { // Load existing config and copy security credentials before validation, // so that security-managed fields (e.g. pico token) are available. - oldCfg, err := config.LoadConfig(h.configPath) + err = cfg.SecurityCopyFrom(h.configPath) if err != nil { - http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } - cfg.SecurityCopyFrom(oldCfg) if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -149,15 +148,14 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { } var newCfg config.Config - if err := json.Unmarshal(merged, &newCfg); err != nil { + if err = json.Unmarshal(merged, &newCfg); err != nil { http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest) return } // Restore security fields (tokens/keys) from the loaded config before validation, // because private fields are lost during JSON round-trip. - newCfg.SecurityCopyFrom(cfg) - if err := newCfg.ApplySecurity(); err != nil { + if err = newCfg.SecurityCopyFrom(h.configPath); err != nil { http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError) return } @@ -261,17 +259,17 @@ func validateConfig(cfg *config.Config) []string { } // Pico channel: token required when enabled - if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" { + if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" { errs = append(errs, "channels.pico.token is required when pico channel is enabled") } // Telegram: token required when enabled - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" { + if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token.String() == "" { errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") } // Discord: token required when enabled - if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" { + if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token.String() == "" { errs = append(errs, "channels.discord.token is required when discord channel is enabled") } @@ -279,7 +277,7 @@ func validateConfig(cfg *config.Config) []string { if cfg.Channels.WeCom.BotID == "" { errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled") } - if cfg.Channels.WeCom.Secret() == "" { + if cfg.Channels.WeCom.Secret.String() == "" { errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled") } } diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 36acd95b0..644284849 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -163,17 +163,11 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) { cfg.ModelList = []*config.ModelConfig{{ ModelName: "custom-default", Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), }} cfg.Agents.Defaults.ModelName = "custom-default" cfg.Channels.Pico.Enabled = true - cfg.WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "custom-default": {APIKeys: []string{"sk-default"}}, - }, - Channels: &config.ChannelsSecurity{ - Pico: &config.PicoSecurity{Token: "test-pico-token"}, - }, - }) + cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token") configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go index c80527fe3..97f153a80 100644 --- a/web/backend/api/models_test.go +++ b/web/backend/api/models_test.go @@ -80,6 +80,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes ModelName: "vllm-remote", Model: "vllm/custom-model", APIBase: "https://models.example.com/v1", + APIKeys: config.SimpleSecureStrings("remote-key"), }, { ModelName: "copilot-gpt-5.4", @@ -88,11 +89,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes AuthMethod: "oauth", }, } - cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{ - "vllm-remote": { - APIKeys: []string{"remote-key"}, - }, - }}) cfg.Agents.Defaults.ModelName = "openai-oauth" if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig() error = %v", err) diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go index 7cab79b52..5aaff8d8f 100644 --- a/web/backend/api/oauth_test.go +++ b/web/backend/api/oauth_test.go @@ -232,15 +232,9 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) { cfg.ModelList = []*config.ModelConfig{{ ModelName: "custom-default", Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), }} cfg.Agents.Defaults.ModelName = "custom-default" - cfg.WithSecurity(&config.SecurityConfig{ - ModelList: map[string]config.ModelSecurityEntry{ - "custom-default": { - APIKeys: []string{"sk-default"}, - }, - }, - }) configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 4faafc2ae..d345d980c 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token(), + "token": cfg.Channels.Pico.Token.String(), "ws_url": wsURL, "enabled": cfg.Channels.Pico.Enabled, }) @@ -110,7 +110,7 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { changed = true } - if cfg.Channels.Pico.Token() == "" { + if cfg.Channels.Pico.Token.String() == "" { cfg.Channels.Pico.SetToken(generateSecureToken()) changed = true } @@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ - "token": cfg.Channels.Pico.Token(), + "token": cfg.Channels.Pico.Token.String(), "ws_url": wsURL, "enabled": true, "changed": changed, diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 051e356cf..aa377975d 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -34,7 +34,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) { if !cfg.Channels.Pico.Enabled { t.Error("expected Pico to be enabled after setup") } - if cfg.Channels.Pico.Token() == "" { + if cfg.Channels.Pico.Token.String() == "" { t.Error("expected a non-empty token after setup") } } @@ -144,8 +144,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } - if cfg.Channels.Pico.Token() != "user-custom-token" { - t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token") + if cfg.Channels.Pico.Token.String() != "user-custom-token" { + t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token") } if !cfg.Channels.Pico.AllowTokenQuery { t.Error("user's allow_token_query=true must be preserved") @@ -185,7 +185,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { if !cfg.Channels.Pico.Enabled { t.Error("expected Pico to be enabled after setup") } - if cfg.Channels.Pico.Token() == "" { + if cfg.Channels.Pico.Token.String() == "" { t.Error("expected a non-empty token after setup") } if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil { @@ -215,7 +215,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { if !cfg.Channels.Pico.Enabled { t.Error("expected Pico to be enabled after launcher startup setup") } - if cfg.Channels.Pico.Token() == "" { + if cfg.Channels.Pico.Token.String() == "" { t.Error("expected a non-empty token after launcher startup setup") } } @@ -232,7 +232,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } cfg1, _ := config.LoadConfig(configPath) - token1 := cfg1.Channels.Pico.Token() + token1 := cfg1.Channels.Pico.Token.String() // Second call should be a no-op changed, err := h.EnsurePicoChannel(origin) @@ -244,7 +244,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } cfg2, _ := config.LoadConfig(configPath) - if cfg2.Channels.Pico.Token() != token1 { + if cfg2.Channels.Pico.Token.String() != token1 { t.Error("token should not change on subsequent calls") } } diff --git a/web/backend/api/weixin_test.go b/web/backend/api/weixin_test.go index 03342b72b..ce54eec16 100644 --- a/web/backend/api/weixin_test.go +++ b/web/backend/api/weixin_test.go @@ -44,7 +44,7 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } - if got := savedCfg.Channels.Weixin.Token(); got != "bot-token" { + if got := savedCfg.Channels.Weixin.Token.String(); got != "bot-token" { t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token") } if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {