refactor config and security to simplified the structure (#2068)
This commit is contained in:
parent
98c78363b3
commit
b646d3b8fe
48 changed files with 1566 additions and 2372 deletions
|
|
@ -121,7 +121,7 @@ func TestApplyWeComAuthResult(t *testing.T) {
|
||||||
|
|
||||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
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.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,7 +151,7 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, cfg.Channels.WeCom.Enabled)
|
assert.True(t, cfg.Channels.WeCom.Enabled)
|
||||||
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
|
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.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
|
||||||
assert.Contains(t, output.String(), "WeCom connected.")
|
assert.Contains(t, output.String(), "WeCom connected.")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,24 +58,21 @@ func TestNewModelCommand(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
{
|
||||||
|
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() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -88,20 +85,16 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "",
|
ModelName: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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() {
|
output := captureStdout(func() {
|
||||||
showCurrentModel(cfg)
|
showCurrentModel(cfg)
|
||||||
|
|
@ -124,25 +117,22 @@ func TestListAvailableModels_Empty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_WithModels(t *testing.T) {
|
func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "gpt-4", Model: "openai/gpt-4"},
|
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
|
||||||
{ModelName: "claude-3", Model: "anthropic/claude-3"},
|
{
|
||||||
|
ModelName: "claude-3",
|
||||||
|
Model: "anthropic/claude-3",
|
||||||
|
APIKeys: config.SecureStrings{config.NewSecureString("test")},
|
||||||
|
},
|
||||||
{ModelName: "no-key-model", Model: "openai/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() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
@ -157,24 +147,25 @@ func TestListAvailableModels_WithModels(t *testing.T) {
|
||||||
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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() {
|
output := captureStdout(func() {
|
||||||
err := setDefaultModel(configPath, cfg, "new-model")
|
err := setDefaultModel(configPath, cfg, "new-model")
|
||||||
|
|
@ -192,20 +183,20 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
|
||||||
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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"))
|
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
|
||||||
}
|
}
|
||||||
|
|
@ -213,24 +204,21 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
|
||||||
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "existing-model",
|
ModelName: "existing-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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"},
|
{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"))
|
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
|
// Use an invalid path to trigger save error
|
||||||
invalidPath := "/nonexistent/directory/config.json"
|
invalidPath := "/nonexistent/directory/config.json"
|
||||||
|
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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")
|
err := setDefaultModel(invalidPath, cfg, "new-model")
|
||||||
|
|
||||||
|
|
@ -284,20 +272,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
// Create a test config
|
// Create a test config
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -315,25 +303,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
|
||||||
func TestModelCommandExecution_Set(t *testing.T) {
|
func TestModelCommandExecution_Set(t *testing.T) {
|
||||||
initTest(t)
|
initTest(t)
|
||||||
|
|
||||||
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
cfg := &config.Config{
|
||||||
"old-model": {
|
|
||||||
APIKeys: []string{"test"},
|
|
||||||
},
|
|
||||||
"new-model": {
|
|
||||||
APIKeys: []string{"test"},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
cfg := (&config.Config{
|
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "old-model",
|
ModelName: "old-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
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)
|
err := config.SaveConfig(configPath, cfg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -357,28 +345,30 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
func TestListAvailableModels_MarkerLogic(t *testing.T) {
|
||||||
cfg := (&config.Config{
|
cfg := &config.Config{
|
||||||
Agents: config.AgentsConfig{
|
Agents: config.AgentsConfig{
|
||||||
Defaults: config.AgentDefaults{
|
Defaults: config.AgentDefaults{
|
||||||
ModelName: "middle-model",
|
ModelName: "middle-model",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "first-model", Model: "openai/first"},
|
{
|
||||||
{ModelName: "middle-model", Model: "openai/middle"},
|
ModelName: "first-model",
|
||||||
{ModelName: "last-model", Model: "openai/last"},
|
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() {
|
output := captureStdout(func() {
|
||||||
listAvailableModels(cfg)
|
listAvailableModels(cfg)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
|
||||||
d.workspace = cfg.WorkspacePath()
|
d.workspace = cfg.WorkspacePath()
|
||||||
installer, err := skills.NewSkillInstaller(
|
installer, err := skills.NewSkillInstaller(
|
||||||
d.workspace,
|
d.workspace,
|
||||||
cfg.Tools.Skills.Github.Token(),
|
cfg.Tools.Skills.Github.Token.String(),
|
||||||
cfg.Tools.Skills.Github.Proxy,
|
cfg.Tools.Skills.Github.Proxy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
|
||||||
ClawHub: skills.ClawHubConfig{
|
ClawHub: skills.ClawHubConfig{
|
||||||
Enabled: clawHubConfig.Enabled,
|
Enabled: clawHubConfig.Enabled,
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
AuthToken: clawHubConfig.AuthToken(),
|
AuthToken: clawHubConfig.AuthToken.String(),
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
|
@ -243,7 +243,7 @@ func skillsSearchCmd(query string) {
|
||||||
ClawHub: skills.ClawHubConfig{
|
ClawHub: skills.ClawHubConfig{
|
||||||
Enabled: clawHubConfig.Enabled,
|
Enabled: clawHubConfig.Enabled,
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
AuthToken: clawHubConfig.AuthToken(),
|
AuthToken: clawHubConfig.AuthToken.String(),
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
|
|
||||||
|
|
@ -165,33 +165,27 @@ func registerSharedTools(
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("web") {
|
if cfg.Tools.IsToolEnabled("web") {
|
||||||
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
|
||||||
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()),
|
BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
|
||||||
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
TavilyAPIKeys: config.MergeAPIKeys(
|
TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(),
|
||||||
cfg.Tools.Web.Tavily.APIKey(),
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
cfg.Tools.Web.Tavily.APIKeys(),
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
),
|
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
||||||
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
|
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
|
||||||
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(),
|
|
||||||
),
|
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
|
||||||
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
|
||||||
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
|
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,
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
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,
|
BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
|
||||||
BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
|
BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
|
||||||
BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
|
BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
|
||||||
|
|
@ -263,7 +257,7 @@ func registerSharedTools(
|
||||||
ClawHub: skills.ClawHubConfig{
|
ClawHub: skills.ClawHubConfig{
|
||||||
Enabled: clawHubConfig.Enabled,
|
Enabled: clawHubConfig.Enabled,
|
||||||
BaseURL: clawHubConfig.BaseURL,
|
BaseURL: clawHubConfig.BaseURL,
|
||||||
AuthToken: clawHubConfig.AuthToken(),
|
AuthToken: clawHubConfig.AuthToken.String(),
|
||||||
SearchPath: clawHubConfig.SearchPath,
|
SearchPath: clawHubConfig.SearchPath,
|
||||||
SkillsPath: clawHubConfig.SkillsPath,
|
SkillsPath: clawHubConfig.SkillsPath,
|
||||||
DownloadPath: clawHubConfig.DownloadPath,
|
DownloadPath: clawHubConfig.DownloadPath,
|
||||||
|
|
|
||||||
|
|
@ -1467,24 +1467,16 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
ModelName: "local",
|
ModelName: "local",
|
||||||
Model: "openai/local-model",
|
Model: "openai/local-model",
|
||||||
APIBase: "https://local.example.invalid/v1",
|
APIBase: "https://local.example.invalid/v1",
|
||||||
|
APIKeys: config.SimpleSecureStrings("test-key"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "deepseek",
|
ModelName: "deepseek",
|
||||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||||
APIBase: "https://openrouter.ai/api/v1",
|
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()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &countingMockProvider{response: "LLM reply"}
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
|
@ -1546,16 +1538,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
|
||||||
ModelName: "local",
|
ModelName: "local",
|
||||||
Model: "openai/local-model",
|
Model: "openai/local-model",
|
||||||
APIBase: "https://local.example.invalid/v1",
|
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()
|
msgBus := bus.NewMessageBus()
|
||||||
provider := &countingMockProvider{response: "LLM reply"}
|
provider := &countingMockProvider{response: "LLM reply"}
|
||||||
|
|
@ -1627,24 +1613,16 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
|
||||||
ModelName: "local",
|
ModelName: "local",
|
||||||
Model: "openai/Qwen3.5-35B-A3B",
|
Model: "openai/Qwen3.5-35B-A3B",
|
||||||
APIBase: localServer.URL,
|
APIBase: localServer.URL,
|
||||||
|
APIKeys: config.SimpleSecureStrings("local-key"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "deepseek",
|
ModelName: "deepseek",
|
||||||
Model: "openrouter/deepseek/deepseek-v3.2",
|
Model: "openrouter/deepseek/deepseek-v3.2",
|
||||||
APIBase: remoteServer.URL,
|
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()
|
msgBus := bus.NewMessageBus()
|
||||||
provider, _, err := providers.CreateProvider(cfg)
|
provider, _, err := providers.CreateProvider(cfg)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ type DingTalkChannel struct {
|
||||||
|
|
||||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||||
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
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")
|
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,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
clientID: cfg.ClientID,
|
clientID: cfg.ClientID,
|
||||||
clientSecret: cfg.ClientSecret(),
|
clientSecret: cfg.ClientSecret.String(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
|
||||||
discordgo.LogDebug: logger.DEBUG,
|
discordgo.LogDebug: logger.DEBUG,
|
||||||
}).Log
|
}).Log
|
||||||
|
|
||||||
session, err := discordgo.New("Bot " + cfg.Token())
|
session, err := discordgo.New("Bot " + cfg.Token.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
tokenCache: tc,
|
tokenCache: tc,
|
||||||
client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
|
||||||
}
|
}
|
||||||
ch.SetOwner(ch)
|
ch.SetOwner(ch)
|
||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
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")
|
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)
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
||||||
|
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
|
@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
c.wsClient = larkws.NewClient(
|
c.wsClient = larkws.NewClient(
|
||||||
c.config.AppID,
|
c.config.AppID,
|
||||||
c.config.AppSecret(),
|
c.config.AppSecret.String(),
|
||||||
larkws.WithEventHandler(dispatcher),
|
larkws.WithEventHandler(dispatcher),
|
||||||
larkws.WithDomain(domain),
|
larkws.WithDomain(domain),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ import (
|
||||||
// onConnect is called after a successful connection (and on reconnect).
|
// onConnect is called after a successful connection (and on reconnect).
|
||||||
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
|
||||||
// NickServ auth (only if SASL is not configured)
|
// NickServ auth (only if SASL is not configured)
|
||||||
if c.config.NickServPassword() != "" && c.config.SASLUser == "" {
|
if c.config.NickServPassword.String() != "" && c.config.SASLUser == "" {
|
||||||
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword())
|
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join configured channels
|
// Join configured channels
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||||
Nick: c.config.Nick,
|
Nick: c.config.Nick,
|
||||||
User: user,
|
User: user,
|
||||||
RealName: realName,
|
RealName: realName,
|
||||||
Password: c.config.Password(),
|
Password: c.config.Password.String(),
|
||||||
UseTLS: c.config.TLS,
|
UseTLS: c.config.TLS,
|
||||||
RequestCaps: caps,
|
RequestCaps: caps,
|
||||||
QuitMessage: "Goodbye",
|
QuitMessage: "Goodbye",
|
||||||
|
|
@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SASL auth (takes priority over NickServ)
|
// 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.SASLLogin = c.config.SASLUser
|
||||||
conn.SASLPassword = c.config.SASLPassword()
|
conn.SASLPassword = c.config.SASLPassword.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ type LINEChannel struct {
|
||||||
|
|
||||||
// NewLINEChannel creates a new LINE channel instance.
|
// NewLINEChannel creates a new LINE channel instance.
|
||||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
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")
|
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 {
|
if err != nil {
|
||||||
return err
|
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)
|
resp, err := c.infoClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret()))
|
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String()))
|
||||||
mac.Write(body)
|
mac.Write(body)
|
||||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
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("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)
|
resp, err := c.apiClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -680,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
LoggerPrefix: "line",
|
LoggerPrefix: "line",
|
||||||
ExtraHeaders: map[string]string{
|
ExtraHeaders: map[string]string{
|
||||||
"Authorization": "Bearer " + c.config.ChannelAccessToken(),
|
"Authorization": "Bearer " + c.config.ChannelAccessToken.String(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -353,7 +353,7 @@ func (m *Manager) initChannel(name, displayName string) {
|
||||||
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
logger.InfoC("channels", "Initializing channel manager")
|
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")
|
m.initChannel("telegram", "Telegram")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,7 +370,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("feishu", "Feishu")
|
m.initChannel("feishu", "Feishu")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Discord.Enabled && channels.Discord.Token() != "" {
|
if channels.Discord.Enabled && channels.Discord.Token.String() != "" {
|
||||||
m.initChannel("discord", "Discord")
|
m.initChannel("discord", "Discord")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -386,18 +386,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("dingtalk", "DingTalk")
|
m.initChannel("dingtalk", "DingTalk")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
|
if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" {
|
||||||
m.initChannel("slack", "Slack")
|
m.initChannel("slack", "Slack")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Matrix.Enabled &&
|
if channels.Matrix.Enabled &&
|
||||||
m.config.Channels.Matrix.Homeserver != "" &&
|
m.config.Channels.Matrix.Homeserver != "" &&
|
||||||
m.config.Channels.Matrix.UserID != "" &&
|
m.config.Channels.Matrix.UserID != "" &&
|
||||||
m.config.Channels.Matrix.AccessToken() != "" {
|
m.config.Channels.Matrix.AccessToken.String() != "" {
|
||||||
m.initChannel("matrix", "Matrix")
|
m.initChannel("matrix", "Matrix")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
|
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" {
|
||||||
m.initChannel("line", "LINE")
|
m.initChannel("line", "LINE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,15 +405,15 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("onebot", "OneBot")
|
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")
|
m.initChannel("wecom", "WeCom")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
|
if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" {
|
||||||
m.initChannel("weixin", "Weixin")
|
m.initChannel("weixin", "Weixin")
|
||||||
}
|
}
|
||||||
|
|
||||||
if channels.Pico.Enabled && channels.Pico.Token() != "" {
|
if channels.Pico.Enabled && channels.Pico.Token.String() != "" {
|
||||||
m.initChannel("pico", "Pico")
|
m.initChannel("pico", "Pico")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,35 +33,35 @@ func toChannelHashes(cfg *config.Config) map[string]string {
|
||||||
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
||||||
switch key {
|
switch key {
|
||||||
case "pico":
|
case "pico":
|
||||||
value["token"] = ch.Pico.Token()
|
value["token"] = ch.Pico.Token.String()
|
||||||
case "telegram":
|
case "telegram":
|
||||||
value["token"] = ch.Telegram.Token()
|
value["token"] = ch.Telegram.Token.String()
|
||||||
case "discord":
|
case "discord":
|
||||||
value["token"] = ch.Discord.Token()
|
value["token"] = ch.Discord.Token.String()
|
||||||
case "slack":
|
case "slack":
|
||||||
value["bot_token"] = ch.Slack.BotToken()
|
value["bot_token"] = ch.Slack.BotToken.String()
|
||||||
value["app_token"] = ch.Slack.AppToken()
|
value["app_token"] = ch.Slack.AppToken.String()
|
||||||
case "matrix":
|
case "matrix":
|
||||||
value["token"] = ch.Matrix.AccessToken()
|
value["token"] = ch.Matrix.AccessToken.String()
|
||||||
case "onebot":
|
case "onebot":
|
||||||
value["token"] = ch.OneBot.AccessToken()
|
value["token"] = ch.OneBot.AccessToken.String()
|
||||||
case "line":
|
case "line":
|
||||||
value["token"] = ch.LINE.ChannelAccessToken()
|
value["token"] = ch.LINE.ChannelAccessToken.String()
|
||||||
value["secret"] = ch.LINE.ChannelSecret()
|
value["secret"] = ch.LINE.ChannelSecret.String()
|
||||||
case "wecom":
|
case "wecom":
|
||||||
value["secret"] = ch.WeCom.Secret()
|
value["secret"] = ch.WeCom.Secret.String()
|
||||||
case "dingtalk":
|
case "dingtalk":
|
||||||
value["secret"] = ch.QQ.AppSecret()
|
value["secret"] = ch.DingTalk.ClientSecret.String()
|
||||||
case "qq":
|
case "qq":
|
||||||
value["secret"] = ch.DingTalk.ClientSecret()
|
value["secret"] = ch.QQ.AppSecret.String()
|
||||||
case "irc":
|
case "irc":
|
||||||
value["password"] = ch.IRC.Password()
|
value["password"] = ch.IRC.Password.String()
|
||||||
value["serv_password"] = ch.IRC.NickServPassword()
|
value["serv_password"] = ch.IRC.NickServPassword.String()
|
||||||
value["sasl_password"] = ch.IRC.SASLPassword()
|
value["sasl_password"] = ch.IRC.SASLPassword.String()
|
||||||
case "feishu":
|
case "feishu":
|
||||||
value["app_secret"] = ch.Feishu.AppSecret()
|
value["app_secret"] = ch.Feishu.AppSecret.String()
|
||||||
value["encrypt_key"] = ch.Feishu.EncryptKey()
|
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
|
||||||
value["verification_token"] = ch.Feishu.VerificationToken()
|
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) {
|
func updateKeys(newcfg, old *config.ChannelsConfig) {
|
||||||
if newcfg.Pico.Enabled {
|
if newcfg.Pico.Enabled {
|
||||||
newcfg.Pico.SetToken(old.Pico.Token())
|
newcfg.Pico.Token = old.Pico.Token
|
||||||
}
|
}
|
||||||
if newcfg.Telegram.Enabled {
|
if newcfg.Telegram.Enabled {
|
||||||
newcfg.Telegram.SetToken(old.Telegram.Token())
|
newcfg.Telegram.Token = old.Telegram.Token
|
||||||
}
|
}
|
||||||
if newcfg.Discord.Enabled {
|
if newcfg.Discord.Enabled {
|
||||||
newcfg.Discord.SetToken(old.Discord.Token())
|
newcfg.Discord.Token = old.Discord.Token
|
||||||
}
|
}
|
||||||
if newcfg.Slack.Enabled {
|
if newcfg.Slack.Enabled {
|
||||||
newcfg.Slack.SetBotToken(old.Slack.BotToken())
|
newcfg.Slack.BotToken = old.Slack.BotToken
|
||||||
newcfg.Slack.SetAppToken(old.Slack.AppToken())
|
newcfg.Slack.AppToken = old.Slack.AppToken
|
||||||
}
|
}
|
||||||
if newcfg.Matrix.Enabled {
|
if newcfg.Matrix.Enabled {
|
||||||
newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken())
|
newcfg.Matrix.AccessToken = old.Matrix.AccessToken
|
||||||
}
|
}
|
||||||
if newcfg.OneBot.Enabled {
|
if newcfg.OneBot.Enabled {
|
||||||
newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken())
|
newcfg.OneBot.AccessToken = old.OneBot.AccessToken
|
||||||
}
|
}
|
||||||
if newcfg.LINE.Enabled {
|
if newcfg.LINE.Enabled {
|
||||||
newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken())
|
newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken
|
||||||
newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
|
newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret
|
||||||
}
|
}
|
||||||
if newcfg.WeCom.Enabled {
|
if newcfg.WeCom.Enabled {
|
||||||
newcfg.WeCom.SetSecret(old.WeCom.Secret())
|
newcfg.WeCom.Secret = old.WeCom.Secret
|
||||||
}
|
}
|
||||||
if newcfg.DingTalk.Enabled {
|
if newcfg.DingTalk.Enabled {
|
||||||
newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
|
newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret
|
||||||
}
|
}
|
||||||
if newcfg.QQ.Enabled {
|
if newcfg.QQ.Enabled {
|
||||||
newcfg.QQ.SetAppSecret(old.QQ.AppSecret())
|
newcfg.QQ.AppSecret = old.QQ.AppSecret
|
||||||
}
|
}
|
||||||
if newcfg.IRC.Enabled {
|
if newcfg.IRC.Enabled {
|
||||||
newcfg.IRC.SetPassword(old.IRC.Password())
|
newcfg.IRC.Password = old.IRC.Password
|
||||||
newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword())
|
newcfg.IRC.NickServPassword = old.IRC.NickServPassword
|
||||||
newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword())
|
newcfg.IRC.SASLPassword = old.IRC.SASLPassword
|
||||||
}
|
}
|
||||||
if newcfg.Feishu.Enabled {
|
if newcfg.Feishu.Enabled {
|
||||||
newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret())
|
newcfg.Feishu.AppSecret = old.Feishu.AppSecret
|
||||||
newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey())
|
newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey
|
||||||
newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken())
|
newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) {
|
||||||
cc, err := toChannelConfig(cfg3, added)
|
cc, err := toChannelConfig(cfg3, added)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
logger.Debugf("cc: %#v", cc.Telegram)
|
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)
|
assert.Equal(t, true, cc.Telegram.Enabled)
|
||||||
cc, err = toChannelConfig(cfg2, added)
|
cc, err = toChannelConfig(cfg2, added)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
logger.Debugf("cc: %#v", cc.Telegram)
|
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)
|
assert.Equal(t, false, cc.Telegram.Enabled)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ func NewMatrixChannel(
|
||||||
) (*MatrixChannel, error) {
|
) (*MatrixChannel, error) {
|
||||||
homeserver := strings.TrimSpace(cfg.Homeserver)
|
homeserver := strings.TrimSpace(cfg.Homeserver)
|
||||||
userID := strings.TrimSpace(cfg.UserID)
|
userID := strings.TrimSpace(cfg.UserID)
|
||||||
accessToken := strings.TrimSpace(cfg.AccessToken())
|
accessToken := strings.TrimSpace(cfg.AccessToken.String())
|
||||||
if homeserver == "" {
|
if homeserver == "" {
|
||||||
return nil, fmt.Errorf("matrix homeserver is required")
|
return nil, fmt.Errorf("matrix homeserver is required")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error {
|
||||||
dialer.HandshakeTimeout = 10 * time.Second
|
dialer.HandshakeTimeout = 10 * time.Second
|
||||||
|
|
||||||
header := make(map[string][]string)
|
header := make(map[string][]string)
|
||||||
if c.config.AccessToken() != "" {
|
if c.config.AccessToken.String() != "" {
|
||||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken()}
|
header["Authorization"] = []string{"Bearer " + c.config.AccessToken.String()}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
conn, resp, err := dialer.Dial(c.config.WSUrl, header)
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,8 @@ func (c *PicoClientChannel) Stop(ctx context.Context) error {
|
||||||
|
|
||||||
func (c *PicoClientChannel) dial() error {
|
func (c *PicoClientChannel) dial() error {
|
||||||
header := http.Header{}
|
header := http.Header{}
|
||||||
if c.config.Token != "" {
|
if c.config.Token.String() != "" {
|
||||||
header.Set("Authorization", "Bearer "+c.config.Token)
|
header.Set("Authorization", "Bearer "+c.config.Token.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
|
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) {
|
||||||
mb := bus.NewMessageBus()
|
mb := bus.NewMessageBus()
|
||||||
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
||||||
URL: wsURL(srv.URL),
|
URL: wsURL(srv.URL),
|
||||||
Token: "test-token",
|
Token: *config.NewSecureString("test-token"),
|
||||||
SessionID: "sess-1",
|
SessionID: "sess-1",
|
||||||
PingInterval: 60,
|
PingInterval: 60,
|
||||||
ReadTimeout: 10,
|
ReadTimeout: 10,
|
||||||
|
|
@ -139,7 +139,7 @@ func TestClientChannel_AuthFailure(t *testing.T) {
|
||||||
|
|
||||||
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
ch, err := NewPicoClientChannel(config.PicoClientConfig{
|
||||||
URL: wsURL(srv.URL),
|
URL: wsURL(srv.URL),
|
||||||
Token: "wrong-token",
|
Token: *config.NewSecureString("wrong-token"),
|
||||||
}, bus.NewMessageBus())
|
}, bus.NewMessageBus())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ type PicoChannel struct {
|
||||||
|
|
||||||
// NewPicoChannel creates a new Pico Protocol channel.
|
// NewPicoChannel creates a new Pico Protocol channel.
|
||||||
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
|
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")
|
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.<value>" (for browsers that can't set headers)
|
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
|
||||||
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
// 3. Query parameter "token" (only when AllowTokenQuery is on)
|
||||||
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
token := c.config.Token()
|
token := c.config.Token.String()
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -412,7 +412,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
|
||||||
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
|
||||||
// the configured token, or "" if none do.
|
// the configured token, or "" if none do.
|
||||||
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
|
||||||
token := c.config.Token()
|
token := c.config.Token.String()
|
||||||
for _, proto := range websocket.Subprotocols(r) {
|
for _, proto := range websocket.Subprotocols(r) {
|
||||||
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
|
||||||
return proto
|
return proto
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *QQChannel) Start(ctx context.Context) error {
|
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")
|
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
|
// create token source
|
||||||
credentials := &token.QQBotCredentials{
|
credentials := &token.QQBotCredentials{
|
||||||
AppID: c.config.AppID,
|
AppID: c.config.AppID,
|
||||||
AppSecret: c.config.AppSecret(),
|
AppSecret: c.config.AppSecret.String(),
|
||||||
}
|
}
|
||||||
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
c.tokenSource = token.NewQQBotTokenSource(credentials)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,13 @@ type slackMessageRef struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
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")
|
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
api := slack.New(
|
api := slack.New(
|
||||||
cfg.BotToken(),
|
cfg.BotToken.String(),
|
||||||
slack.OptionAppLevelToken(cfg.AppToken()),
|
slack.OptionAppLevelToken(cfg.AppToken.String()),
|
||||||
)
|
)
|
||||||
|
|
||||||
socketClient := socketmode.New(api)
|
socketClient := socketmode.New(api)
|
||||||
|
|
@ -516,7 +516,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
|
||||||
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
|
||||||
LoggerPrefix: "slack",
|
LoggerPrefix: "slack",
|
||||||
ExtraHeaders: map[string]string{
|
ExtraHeaders: map[string]string{
|
||||||
"Authorization": "Bearer " + c.config.BotToken(),
|
"Authorization": "Bearer " + c.config.BotToken.String(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
|
|
||||||
t.Run("missing bot token", func(t *testing.T) {
|
t.Run("missing bot token", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{}
|
cfg := config.SlackConfig{}
|
||||||
cfg.SetAppToken("xapp-test")
|
cfg.AppToken = *config.NewSecureString("xapp-test")
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing bot_token, got 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) {
|
t.Run("missing app token", func(t *testing.T) {
|
||||||
cfg := config.SlackConfig{}
|
cfg := config.SlackConfig{}
|
||||||
cfg.SetBotToken("xoxb-test")
|
cfg.BotToken = *config.NewSecureString("xoxb-test")
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing app_token, got nil")
|
t.Error("expected error for missing app_token, got nil")
|
||||||
|
|
@ -123,8 +123,8 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
AllowFrom: []string{"U123"},
|
AllowFrom: []string{"U123"},
|
||||||
}
|
}
|
||||||
cfg.SetBotToken("xoxb-test")
|
cfg.BotToken = *config.NewSecureString("xoxb-test")
|
||||||
cfg.SetAppToken("xapp-test")
|
cfg.AppToken = *config.NewSecureString("xapp-test")
|
||||||
ch, err := NewSlackChannel(cfg, msgBus)
|
ch, err := NewSlackChannel(cfg, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
|
@ -145,8 +145,8 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
AllowFrom: []string{},
|
AllowFrom: []string{},
|
||||||
}
|
}
|
||||||
cfg.SetBotToken("xoxb-test")
|
cfg.BotToken = *config.NewSecureString("xoxb-test")
|
||||||
cfg.SetAppToken("xapp-test")
|
cfg.AppToken = *config.NewSecureString("xapp-test")
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("U_ANYONE") {
|
if !ch.IsAllowed("U_ANYONE") {
|
||||||
t.Error("empty allowlist should allow all users")
|
t.Error("empty allowlist should allow all users")
|
||||||
|
|
@ -157,8 +157,8 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
cfg := config.SlackConfig{
|
cfg := config.SlackConfig{
|
||||||
AllowFrom: []string{"U_ALLOWED"},
|
AllowFrom: []string{"U_ALLOWED"},
|
||||||
}
|
}
|
||||||
cfg.SetBotToken("xoxb-test")
|
cfg.BotToken = *config.NewSecureString("xoxb-test")
|
||||||
cfg.SetAppToken("xapp-test")
|
cfg.AppToken = *config.NewSecureString("xapp-test")
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, msgBus)
|
||||||
if !ch.IsAllowed("U_ALLOWED") {
|
if !ch.IsAllowed("U_ALLOWED") {
|
||||||
t.Error("allowed user should pass allowlist check")
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
}
|
}
|
||||||
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ func (s *recentMessageSet) Mark(id string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) {
|
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")
|
return nil, fmt.Errorf("wecom bot_id and secret are required")
|
||||||
}
|
}
|
||||||
if cfg.WebSocketURL == "" {
|
if cfg.WebSocketURL == "" {
|
||||||
|
|
@ -356,7 +356,7 @@ func (c *WeComChannel) runConnection() error {
|
||||||
Headers: wecomHeaders{ReqID: randomID(10)},
|
Headers: wecomHeaders{ReqID: randomID(10)},
|
||||||
Body: map[string]string{
|
Body: map[string]string{
|
||||||
"bot_id": c.config.BotID,
|
"bot_id": c.config.BotID,
|
||||||
"secret": c.config.Secret(),
|
"secret": c.config.Secret.String(),
|
||||||
},
|
},
|
||||||
}, wecomCommandTimeout); writeErr != nil {
|
}, wecomCommandTimeout); writeErr != nil {
|
||||||
return writeErr
|
return writeErr
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func picoclawHomeDir() string {
|
||||||
|
|
||||||
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
|
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
|
||||||
key := "default"
|
key := "default"
|
||||||
token := strings.TrimSpace(cfg.Token())
|
token := strings.TrimSpace(cfg.Token.String())
|
||||||
if token != "" {
|
if token != "" {
|
||||||
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
|
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
|
||||||
key = hex.EncodeToString(sum[:8])
|
key = hex.EncodeToString(sum[:8])
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func init() {
|
||||||
|
|
||||||
// NewWeixinChannel creates a new WeixinChannel from config.
|
// NewWeixinChannel creates a new WeixinChannel from config.
|
||||||
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
|
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1433
pkg/config/config.go
1433
pkg/config/config.go
File diff suppressed because it is too large
Load diff
|
|
@ -102,53 +102,39 @@ type channelsConfigV0 struct {
|
||||||
IRC ircConfigV0 `json:"irc"`
|
IRC ircConfigV0 `json:"irc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
|
func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig {
|
||||||
telegram, telegramSecurity := v.Telegram.ToTelegramConfig()
|
telegram := v.Telegram.ToTelegramConfig()
|
||||||
feishu, feishuSecurity := v.Feishu.ToFeishuConfig()
|
feishu := v.Feishu.ToFeishuConfig()
|
||||||
discord, discordSecurity := v.Discord.ToDiscordConfig()
|
discord := v.Discord.ToDiscordConfig()
|
||||||
maixcam := v.MaixCam.ToMaixCamConfig()
|
maixcam := v.MaixCam.ToMaixCamConfig()
|
||||||
qq, qqSecurity := v.QQ.ToQQConfig()
|
qq := v.QQ.ToQQConfig()
|
||||||
weixin, weixinSecurity := v.Weixin.ToWeiXinConfig()
|
weixin := v.Weixin.ToWeiXinConfig()
|
||||||
dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig()
|
dingtalk := v.DingTalk.ToDingTalkConfig()
|
||||||
slack, slackSecurity := v.Slack.ToSlackConfig()
|
slack := v.Slack.ToSlackConfig()
|
||||||
matrix, matrixSecurity := v.Matrix.ToMatrixConfig()
|
matrix := v.Matrix.ToMatrixConfig()
|
||||||
line, lineSecurity := v.LINE.ToLINEConfig()
|
line := v.LINE.ToLINEConfig()
|
||||||
onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
|
onebot := v.OneBot.ToOneBotConfig()
|
||||||
wecom, wecomSecurity := v.WeCom.ToWeComConfig()
|
wecom := v.WeCom.ToWeComConfig()
|
||||||
pico, picoSecurity := v.Pico.ToPicoConfig()
|
pico := v.Pico.ToPicoConfig()
|
||||||
irc, ircSecurity := v.IRC.ToIRCConfig()
|
irc := v.IRC.ToIRCConfig()
|
||||||
|
|
||||||
return ChannelsConfig{
|
return ChannelsConfig{
|
||||||
WhatsApp: v.WhatsApp,
|
WhatsApp: v.WhatsApp,
|
||||||
Telegram: telegram,
|
Telegram: telegram,
|
||||||
Feishu: feishu,
|
Feishu: feishu,
|
||||||
Discord: discord,
|
Discord: discord,
|
||||||
MaixCam: maixcam,
|
MaixCam: maixcam,
|
||||||
QQ: qq,
|
QQ: qq,
|
||||||
Weixin: weixin,
|
Weixin: weixin,
|
||||||
DingTalk: dingtalk,
|
DingTalk: dingtalk,
|
||||||
Slack: slack,
|
Slack: slack,
|
||||||
Matrix: matrix,
|
Matrix: matrix,
|
||||||
LINE: line,
|
LINE: line,
|
||||||
OneBot: onebot,
|
OneBot: onebot,
|
||||||
WeCom: wecom,
|
WeCom: wecom,
|
||||||
Pico: pico,
|
Pico: pico,
|
||||||
IRC: irc,
|
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type qqConfigV0 struct {
|
type qqConfigV0 struct {
|
||||||
|
|
@ -163,13 +149,7 @@ type qqConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
|
func (v *qqConfigV0) ToQQConfig() QQConfig {
|
||||||
var sec *QQSecurity
|
|
||||||
if v.AppSecret != "" {
|
|
||||||
sec = &QQSecurity{
|
|
||||||
AppSecret: v.AppSecret,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return QQConfig{
|
return QQConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
AppID: v.AppID,
|
AppID: v.AppID,
|
||||||
|
|
@ -179,7 +159,8 @@ func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
|
||||||
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
|
||||||
SendMarkdown: v.SendMarkdown,
|
SendMarkdown: v.SendMarkdown,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
AppSecret: *NewSecureString(v.AppSecret),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type telegramConfigV0 struct {
|
type telegramConfigV0 struct {
|
||||||
|
|
@ -195,16 +176,9 @@ type telegramConfigV0 struct {
|
||||||
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) {
|
func (v *telegramConfigV0) ToTelegramConfig() TelegramConfig {
|
||||||
var sec *TelegramSecurity
|
cfg := TelegramConfig{
|
||||||
if v.Token != "" {
|
|
||||||
sec = &TelegramSecurity{
|
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TelegramConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
|
|
@ -213,7 +187,11 @@ func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
UseMarkdownV2: v.UseMarkdownV2,
|
UseMarkdownV2: v.UseMarkdownV2,
|
||||||
}, sec
|
}
|
||||||
|
if v.Token != "" {
|
||||||
|
cfg.Token = *NewSecureString(v.Token)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type feishuConfigV0 struct {
|
type feishuConfigV0 struct {
|
||||||
|
|
@ -230,24 +208,25 @@ type feishuConfigV0 struct {
|
||||||
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, *FeishuSecurity) {
|
func (v *feishuConfigV0) ToFeishuConfig() FeishuConfig {
|
||||||
var sec *FeishuSecurity
|
cfg := FeishuConfig{
|
||||||
if v.AppSecret != "" || v.EncryptKey != "" || v.VerificationToken != "" {
|
|
||||||
sec = &FeishuSecurity{
|
|
||||||
AppSecret: v.AppSecret,
|
|
||||||
EncryptKey: v.EncryptKey,
|
|
||||||
VerificationToken: v.VerificationToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return FeishuConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
AppID: v.AppID,
|
AppID: v.AppID,
|
||||||
appSecret: v.AppSecret,
|
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
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 {
|
type discordConfigV0 struct {
|
||||||
|
|
@ -262,16 +241,9 @@ type discordConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
|
func (v *discordConfigV0) ToDiscordConfig() DiscordConfig {
|
||||||
var sec *DiscordSecurity
|
cfg := DiscordConfig{
|
||||||
if v.Token != "" {
|
|
||||||
sec = &DiscordSecurity{
|
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DiscordConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
MentionOnly: v.MentionOnly,
|
MentionOnly: v.MentionOnly,
|
||||||
|
|
@ -279,7 +251,11 @@ func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.Token != "" {
|
||||||
|
cfg.Token = *NewSecureString(v.Token)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type maixcamConfigV0 struct {
|
type maixcamConfigV0 struct {
|
||||||
|
|
@ -309,21 +285,18 @@ type dingtalkConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) {
|
func (v *dingtalkConfigV0) ToDingTalkConfig() DingTalkConfig {
|
||||||
var sec *DingTalkSecurity
|
cfg := DingTalkConfig{
|
||||||
if v.ClientSecret != "" {
|
|
||||||
sec = &DingTalkSecurity{
|
|
||||||
ClientSecret: v.ClientSecret,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return DingTalkConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
ClientID: v.ClientID,
|
ClientID: v.ClientID,
|
||||||
clientSecret: v.ClientSecret,
|
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.ClientSecret != "" {
|
||||||
|
cfg.ClientSecret = *NewSecureString(v.ClientSecret)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type slackConfigV0 struct {
|
type slackConfigV0 struct {
|
||||||
|
|
@ -337,24 +310,22 @@ type slackConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) {
|
func (v *slackConfigV0) ToSlackConfig() SlackConfig {
|
||||||
var sec *SlackSecurity
|
cfg := SlackConfig{
|
||||||
if v.BotToken != "" || v.AppToken != "" {
|
|
||||||
sec = &SlackSecurity{
|
|
||||||
BotToken: v.BotToken,
|
|
||||||
AppToken: v.AppToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SlackConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
botToken: v.BotToken,
|
|
||||||
appToken: v.AppToken,
|
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
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 {
|
type matrixConfigV0 struct {
|
||||||
|
|
@ -371,18 +342,11 @@ type matrixConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
|
func (v *matrixConfigV0) ToMatrixConfig() MatrixConfig {
|
||||||
var sec *MatrixSecurity
|
cfg := MatrixConfig{
|
||||||
if v.AccessToken != "" {
|
|
||||||
sec = &MatrixSecurity{
|
|
||||||
AccessToken: v.AccessToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return MatrixConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
Homeserver: v.Homeserver,
|
Homeserver: v.Homeserver,
|
||||||
UserID: v.UserID,
|
UserID: v.UserID,
|
||||||
accessToken: v.AccessToken,
|
|
||||||
DeviceID: v.DeviceID,
|
DeviceID: v.DeviceID,
|
||||||
JoinOnInvite: v.JoinOnInvite,
|
JoinOnInvite: v.JoinOnInvite,
|
||||||
MessageFormat: v.MessageFormat,
|
MessageFormat: v.MessageFormat,
|
||||||
|
|
@ -390,7 +354,11 @@ func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.AccessToken != "" {
|
||||||
|
cfg.AccessToken = *NewSecureString(v.AccessToken)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type lineConfigV0 struct {
|
type lineConfigV0 struct {
|
||||||
|
|
@ -407,18 +375,9 @@ type lineConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
|
func (v *lineConfigV0) ToLINEConfig() LINEConfig {
|
||||||
var sec *LINESecurity
|
cfg := LINEConfig{
|
||||||
if v.ChannelSecret != "" || v.ChannelAccessToken != "" {
|
|
||||||
sec = &LINESecurity{
|
|
||||||
ChannelSecret: v.ChannelSecret,
|
|
||||||
ChannelAccessToken: v.ChannelAccessToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return LINEConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
channelSecret: v.ChannelSecret,
|
|
||||||
channelAccessToken: v.ChannelAccessToken,
|
|
||||||
WebhookHost: v.WebhookHost,
|
WebhookHost: v.WebhookHost,
|
||||||
WebhookPort: v.WebhookPort,
|
WebhookPort: v.WebhookPort,
|
||||||
WebhookPath: v.WebhookPath,
|
WebhookPath: v.WebhookPath,
|
||||||
|
|
@ -427,7 +386,14 @@ func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
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 {
|
type onebotConfigV0 struct {
|
||||||
|
|
@ -443,17 +409,10 @@ type onebotConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
|
func (v *onebotConfigV0) ToOneBotConfig() OneBotConfig {
|
||||||
var sec *OneBotSecurity
|
cfg := OneBotConfig{
|
||||||
if v.AccessToken != "" {
|
|
||||||
sec = &OneBotSecurity{
|
|
||||||
AccessToken: v.AccessToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return OneBotConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
WSUrl: v.WSUrl,
|
WSUrl: v.WSUrl,
|
||||||
accessToken: v.AccessToken,
|
|
||||||
ReconnectInterval: v.ReconnectInterval,
|
ReconnectInterval: v.ReconnectInterval,
|
||||||
GroupTriggerPrefix: v.GroupTriggerPrefix,
|
GroupTriggerPrefix: v.GroupTriggerPrefix,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
|
|
@ -461,7 +420,11 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.AccessToken != "" {
|
||||||
|
cfg.AccessToken = *NewSecureString(v.AccessToken)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type wecomConfigV0 struct {
|
type wecomConfigV0 struct {
|
||||||
|
|
@ -478,20 +441,19 @@ type wecomConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) {
|
func (v *wecomConfigV0) ToWeComConfig() WeComConfig {
|
||||||
var sec *WeComSecurity
|
cfg := WeComConfig{
|
||||||
if v.Secret != "" {
|
|
||||||
sec = &WeComSecurity{Secret: v.Secret}
|
|
||||||
}
|
|
||||||
return WeComConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
BotID: v.BotID,
|
BotID: v.BotID,
|
||||||
secret: v.Secret,
|
|
||||||
WebSocketURL: v.WebSocketURL,
|
WebSocketURL: v.WebSocketURL,
|
||||||
SendThinkingMessage: v.SendThinkingMessage,
|
SendThinkingMessage: v.SendThinkingMessage,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.Secret != "" {
|
||||||
|
cfg.Secret = *NewSecureString(v.Secret)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type weixinConfigV0 struct {
|
type weixinConfigV0 struct {
|
||||||
|
|
@ -504,22 +466,19 @@ type weixinConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
|
func (v *weixinConfigV0) ToWeiXinConfig() WeixinConfig {
|
||||||
var sec *WeixinSecurity
|
cfg := WeixinConfig{
|
||||||
if v.Token != "" {
|
|
||||||
sec = &WeixinSecurity{
|
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return WeixinConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
CDNBaseURL: v.CDNBaseURL,
|
CDNBaseURL: v.CDNBaseURL,
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
ReasoningChannelID: v.ReasoningChannelID,
|
||||||
}, sec
|
}
|
||||||
|
if v.Token != "" {
|
||||||
|
cfg.Token = *NewSecureString(v.Token)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type picoConfigV0 struct {
|
type picoConfigV0 struct {
|
||||||
|
|
@ -535,16 +494,9 @@ type picoConfigV0 struct {
|
||||||
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
|
func (v *picoConfigV0) ToPicoConfig() PicoConfig {
|
||||||
var sec *PicoSecurity
|
cfg := PicoConfig{
|
||||||
if v.Token != "" {
|
|
||||||
sec = &PicoSecurity{
|
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return PicoConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
token: v.Token,
|
|
||||||
AllowTokenQuery: v.AllowTokenQuery,
|
AllowTokenQuery: v.AllowTokenQuery,
|
||||||
AllowOrigins: v.AllowOrigins,
|
AllowOrigins: v.AllowOrigins,
|
||||||
PingInterval: v.PingInterval,
|
PingInterval: v.PingInterval,
|
||||||
|
|
@ -553,7 +505,11 @@ func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
|
||||||
MaxConnections: v.MaxConnections,
|
MaxConnections: v.MaxConnections,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
Placeholder: v.Placeholder,
|
Placeholder: v.Placeholder,
|
||||||
}, sec
|
}
|
||||||
|
if v.Token != "" {
|
||||||
|
cfg.Token = *NewSecureString(v.Token)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type ircConfigV0 struct {
|
type ircConfigV0 struct {
|
||||||
|
|
@ -575,33 +531,32 @@ type ircConfigV0 struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, *IRCSecurity) {
|
func (v *ircConfigV0) ToIRCConfig() IRCConfig {
|
||||||
var sec *IRCSecurity
|
cfg := IRCConfig{
|
||||||
if v.Password != "" || v.NickServPassword != "" || v.SASLPassword != "" {
|
|
||||||
sec = &IRCSecurity{
|
|
||||||
Password: v.Password,
|
|
||||||
NickServPassword: v.NickServPassword,
|
|
||||||
SASLPassword: v.SASLPassword,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return IRCConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
Server: v.Server,
|
Server: v.Server,
|
||||||
TLS: v.TLS,
|
TLS: v.TLS,
|
||||||
Nick: v.Nick,
|
Nick: v.Nick,
|
||||||
User: v.User,
|
User: v.User,
|
||||||
RealName: v.RealName,
|
RealName: v.RealName,
|
||||||
password: v.Password,
|
|
||||||
nickServPassword: v.NickServPassword,
|
|
||||||
SASLUser: v.SASLUser,
|
SASLUser: v.SASLUser,
|
||||||
saslPassword: v.SASLPassword,
|
|
||||||
Channels: v.Channels,
|
Channels: v.Channels,
|
||||||
RequestCaps: v.RequestCaps,
|
RequestCaps: v.RequestCaps,
|
||||||
AllowFrom: v.AllowFrom,
|
AllowFrom: v.AllowFrom,
|
||||||
GroupTrigger: v.GroupTrigger,
|
GroupTrigger: v.GroupTrigger,
|
||||||
Typing: v.Typing,
|
Typing: v.Typing,
|
||||||
ReasoningChannelID: v.ReasoningChannelID,
|
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 {
|
type providersConfigV0 struct {
|
||||||
|
|
@ -748,15 +703,12 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
// Copy other top-level fields
|
// Copy other top-level fields
|
||||||
cfg.Bindings = c.Bindings
|
cfg.Bindings = c.Bindings
|
||||||
cfg.Session = c.Session
|
cfg.Session = c.Session
|
||||||
var secChannels ChannelsSecurity
|
cfg.Channels = c.Channels.ToChannelsConfig()
|
||||||
cfg.Channels, secChannels = c.Channels.ToChannelsConfig()
|
|
||||||
cfg.Gateway = c.Gateway
|
cfg.Gateway = c.Gateway
|
||||||
var secWeb WebToolsSecurity
|
cfg.Tools.Web = c.Tools.Web.ToWebToolsConfig()
|
||||||
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
|
|
||||||
cfg.Tools.Cron = c.Tools.Cron
|
cfg.Tools.Cron = c.Tools.Cron
|
||||||
cfg.Tools.Exec = c.Tools.Exec
|
cfg.Tools.Exec = c.Tools.Exec
|
||||||
var secSkills *SkillsSecurity
|
cfg.Tools.Skills = c.Tools.Skills.ToSkillsToolsConfig()
|
||||||
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
|
|
||||||
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
|
||||||
cfg.Tools.MCP = c.Tools.MCP
|
cfg.Tools.MCP = c.Tools.MCP
|
||||||
cfg.Tools.AppendFile = c.Tools.AppendFile
|
cfg.Tools.AppendFile = c.Tools.AppendFile
|
||||||
|
|
@ -778,15 +730,10 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
cfg.Heartbeat = c.Heartbeat
|
cfg.Heartbeat = c.Heartbeat
|
||||||
cfg.Devices = c.Devices
|
cfg.Devices = c.Devices
|
||||||
|
|
||||||
secModels := make(map[string]ModelSecurityEntry, 0)
|
|
||||||
// Only override ModelList if user provided values
|
|
||||||
if len(c.ModelList) > 0 {
|
if len(c.ModelList) > 0 {
|
||||||
// Convert []modelConfigV0 to []ModelConfig
|
// Convert []modelConfigV0 to []ModelConfig
|
||||||
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
|
||||||
for i, m := range c.ModelList {
|
for i, m := range c.ModelList {
|
||||||
// Merge APIKey and APIKeys, deduplicating
|
|
||||||
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
|
|
||||||
|
|
||||||
cfg.ModelList[i] = &ModelConfig{
|
cfg.ModelList[i] = &ModelConfig{
|
||||||
ModelName: m.ModelName,
|
ModelName: m.ModelName,
|
||||||
Model: m.Model,
|
Model: m.Model,
|
||||||
|
|
@ -800,27 +747,11 @@ func (c *configV0) Migrate() (*Config, error) {
|
||||||
MaxTokensField: m.MaxTokensField,
|
MaxTokensField: m.MaxTokensField,
|
||||||
RequestTimeout: m.RequestTimeout,
|
RequestTimeout: m.RequestTimeout,
|
||||||
ThinkingLevel: m.ThinkingLevel,
|
ThinkingLevel: m.ThinkingLevel,
|
||||||
apiKeys: mergedKeys,
|
APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)),
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.WithSecurity(&SecurityConfig{
|
|
||||||
ModelList: secModels,
|
|
||||||
Channels: &secChannels,
|
|
||||||
Web: &secWeb,
|
|
||||||
Skills: secSkills,
|
|
||||||
})
|
|
||||||
cfg.Version = CurrentVersion
|
cfg.Version = CurrentVersion
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
@ -848,17 +779,20 @@ type braveConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, *BraveSecurity) {
|
func toSecureStrings(keys []string) SecureStrings {
|
||||||
var sec *BraveSecurity
|
apikeys := make(SecureStrings, len(keys))
|
||||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
for i, key := range keys {
|
||||||
sec = &BraveSecurity{
|
apikeys[i] = NewSecureString(key)
|
||||||
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return apikeys
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *braveConfigV0) ToBraveConfig() BraveConfig {
|
||||||
return BraveConfig{
|
return BraveConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, sec
|
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type tavilyConfigV0 struct {
|
type tavilyConfigV0 struct {
|
||||||
|
|
@ -869,18 +803,13 @@ type tavilyConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) {
|
func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig {
|
||||||
var sec *TavilySecurity
|
|
||||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
|
||||||
sec = &TavilySecurity{
|
|
||||||
APIKeys: k,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TavilyConfig{
|
return TavilyConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, sec
|
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type perplexityConfigV0 struct {
|
type perplexityConfigV0 struct {
|
||||||
|
|
@ -890,17 +819,12 @@ type perplexityConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) {
|
func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig {
|
||||||
var sec *PerplexitySecurity
|
|
||||||
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
|
|
||||||
sec = &PerplexitySecurity{
|
|
||||||
APIKeys: k,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return PerplexityConfig{
|
return PerplexityConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, sec
|
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type glmSearchConfigV0 struct {
|
type glmSearchConfigV0 struct {
|
||||||
|
|
@ -910,19 +834,13 @@ type glmSearchConfigV0 struct {
|
||||||
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) {
|
func (v *glmSearchConfigV0) ToGLMSearchConfig() GLMSearchConfig {
|
||||||
var sec *GLMSearchSecurity
|
|
||||||
if v.APIKey != "" {
|
|
||||||
sec = &GLMSearchSecurity{
|
|
||||||
APIKey: v.APIKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return GLMSearchConfig{
|
return GLMSearchConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
apiKey: v.APIKey,
|
APIKey: *NewSecureString(v.APIKey),
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
SearchEngine: v.SearchEngine,
|
SearchEngine: v.SearchEngine,
|
||||||
}, sec
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type baiduSearchConfigV0 struct {
|
type baiduSearchConfigV0 struct {
|
||||||
|
|
@ -932,49 +850,37 @@ type baiduSearchConfigV0 struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *baiduSearchConfigV0) ToBaiduSearchConfig() (BaiduSearchConfig, *BaiduSearchSecurity) {
|
func (v *baiduSearchConfigV0) ToBaiduSearchConfig() BaiduSearchConfig {
|
||||||
var sec *BaiduSearchSecurity
|
|
||||||
if v.APIKey != "" {
|
|
||||||
sec = &BaiduSearchSecurity{
|
|
||||||
APIKey: v.APIKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return BaiduSearchConfig{
|
return BaiduSearchConfig{
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
apiKey: v.APIKey,
|
APIKey: *NewSecureString(v.APIKey),
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
MaxResults: v.MaxResults,
|
MaxResults: v.MaxResults,
|
||||||
}, sec
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
|
func (v *webToolsConfigV0) ToWebToolsConfig() WebToolsConfig {
|
||||||
brave, braveSecurity := v.Brave.ToBraveConfig()
|
brave := v.Brave.ToBraveConfig()
|
||||||
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
|
tavily := v.Tavily.ToTavilyConfig()
|
||||||
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
|
perplexity := v.Perplexity.ToPerplexityConfig()
|
||||||
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
|
glmSearch := v.GLMSearch.ToGLMSearchConfig()
|
||||||
baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig()
|
baiduSearch := v.BaiduSearch.ToBaiduSearchConfig()
|
||||||
|
|
||||||
return WebToolsConfig{
|
return WebToolsConfig{
|
||||||
ToolConfig: v.ToolConfig,
|
ToolConfig: v.ToolConfig,
|
||||||
Brave: brave,
|
Brave: brave,
|
||||||
Tavily: tavily,
|
Tavily: tavily,
|
||||||
DuckDuckGo: v.DuckDuckGo,
|
DuckDuckGo: v.DuckDuckGo,
|
||||||
Perplexity: perplexity,
|
Perplexity: perplexity,
|
||||||
SearXNG: v.SearXNG,
|
SearXNG: v.SearXNG,
|
||||||
GLMSearch: glmSearch,
|
GLMSearch: glmSearch,
|
||||||
BaiduSearch: baiduSearch,
|
PreferNative: v.PreferNative,
|
||||||
PreferNative: v.PreferNative,
|
Proxy: v.Proxy,
|
||||||
Proxy: v.Proxy,
|
FetchLimitBytes: v.FetchLimitBytes,
|
||||||
FetchLimitBytes: v.FetchLimitBytes,
|
Format: v.Format,
|
||||||
Format: v.Format,
|
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
||||||
PrivateHostWhitelist: v.PrivateHostWhitelist,
|
BaiduSearch: baiduSearch,
|
||||||
}, WebToolsSecurity{
|
}
|
||||||
Brave: braveSecurity,
|
|
||||||
Tavily: tavilySecurity,
|
|
||||||
Perplexity: perplexitySecurity,
|
|
||||||
GLMSearch: glmSearchSecurity,
|
|
||||||
BaiduSearch: baiduSearchSecurity,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type skillsToolsConfigV0 struct {
|
type skillsToolsConfigV0 struct {
|
||||||
|
|
@ -997,20 +903,17 @@ type clawHubRegistryConfigV0 struct {
|
||||||
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) {
|
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() ClawHubRegistryConfig {
|
||||||
var sec *ClawHubSecurity
|
cfg := ClawHubRegistryConfig{
|
||||||
if v.AuthToken != "" {
|
|
||||||
sec = &ClawHubSecurity{
|
|
||||||
AuthToken: v.AuthToken,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ClawHubRegistryConfig{
|
|
||||||
Enabled: v.Enabled,
|
Enabled: v.Enabled,
|
||||||
BaseURL: v.BaseURL,
|
BaseURL: v.BaseURL,
|
||||||
authToken: v.AuthToken,
|
|
||||||
SearchPath: v.SearchPath,
|
SearchPath: v.SearchPath,
|
||||||
SkillsPath: v.SkillsPath,
|
SkillsPath: v.SkillsPath,
|
||||||
}, sec
|
}
|
||||||
|
if v.AuthToken != "" {
|
||||||
|
cfg.AuthToken = *NewSecureString(v.AuthToken)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type skillsGithubConfigV0 struct {
|
type skillsGithubConfigV0 struct {
|
||||||
|
|
@ -1018,43 +921,29 @@ type skillsGithubConfigV0 struct {
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) {
|
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() SkillsGithubConfig {
|
||||||
var sec *GithubSecurity
|
|
||||||
if v.Token != "" {
|
|
||||||
sec = &GithubSecurity{
|
|
||||||
Token: v.Token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SkillsGithubConfig{
|
return SkillsGithubConfig{
|
||||||
token: v.Token,
|
Token: *NewSecureString(v.Token),
|
||||||
Proxy: v.Proxy,
|
Proxy: v.Proxy,
|
||||||
}, sec
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
|
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() SkillsRegistriesConfig {
|
||||||
clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig()
|
clawHub := v.ClawHub.ToClawHubRegistryConfig()
|
||||||
|
|
||||||
return SkillsRegistriesConfig{
|
return SkillsRegistriesConfig{
|
||||||
ClawHub: clawHub,
|
ClawHub: clawHub,
|
||||||
}, clawHubSecurity
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) {
|
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() SkillsToolsConfig {
|
||||||
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
|
registries := v.Registries.ToSkillsRegistriesConfig()
|
||||||
github, githubSecurity := v.Github.ToSkillsGithubConfig()
|
github := v.Github.ToSkillsGithubConfig()
|
||||||
|
|
||||||
var sec *SkillsSecurity
|
|
||||||
if githubSecurity != nil || registriesSecurity != nil {
|
|
||||||
sec = &SkillsSecurity{
|
|
||||||
Github: githubSecurity,
|
|
||||||
ClawHub: registriesSecurity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return SkillsToolsConfig{
|
return SkillsToolsConfig{
|
||||||
ToolConfig: v.ToolConfig,
|
ToolConfig: v.ToolConfig,
|
||||||
Registries: registries,
|
Registries: registries,
|
||||||
Github: github,
|
Github: github,
|
||||||
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
MaxConcurrentSearches: v.MaxConcurrentSearches,
|
||||||
SearchCache: v.SearchCache,
|
SearchCache: v.SearchCache,
|
||||||
}, sec
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
|
||||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
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")
|
t.Error("Brave API key should be empty by default")
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||||
|
|
@ -403,12 +403,12 @@ func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
|
||||||
primaryModel := &ModelConfig{
|
primaryModel := &ModelConfig{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
apiKeys: []string{"key1"},
|
APIKeys: SimpleSecureStrings("key1"),
|
||||||
}
|
}
|
||||||
virtualModel := &ModelConfig{
|
virtualModel := &ModelConfig{
|
||||||
ModelName: "gpt-4__key_1",
|
ModelName: "gpt-4__key_1",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
apiKeys: []string{"key2"},
|
APIKeys: SimpleSecureStrings("key2"),
|
||||||
isVirtual: true,
|
isVirtual: true,
|
||||||
}
|
}
|
||||||
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
|
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
|
||||||
|
|
@ -1064,11 +1064,10 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.ModelList = []*ModelConfig{
|
cfg.ModelList = []*ModelConfig{
|
||||||
{ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}},
|
{ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("")},
|
||||||
}
|
|
||||||
cfg.security = &SecurityConfig{
|
|
||||||
ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}},
|
|
||||||
}
|
}
|
||||||
|
cfg.ModelList[0].APIKeys[0].Set("sk-plaintext")
|
||||||
|
|
||||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -1132,8 +1131,9 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
|
||||||
secPath := filepath.Join(dir, SecurityConfigFile)
|
secPath := filepath.Join(dir, SecurityConfigFile)
|
||||||
if err := saveSecurityConfig(
|
if err := saveSecurityConfig(
|
||||||
secPath,
|
secPath,
|
||||||
&SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}},
|
&Config{ModelList: SecureModelList{
|
||||||
); err != nil {
|
&ModelConfig{ModelName: "test", APIKeys: SimpleSecureStrings("file://openai.key")},
|
||||||
|
}}); err != nil {
|
||||||
t.Fatalf("saveSecurityConfig: %v", err)
|
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.
|
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
|
||||||
if err := SaveConfig(cfgPath, &Config{
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "pre", Model: "openai/gpt-4"},
|
{ModelName: "pre", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-already-plain")},
|
||||||
},
|
|
||||||
security: &SecurityConfig{
|
|
||||||
ModelList: map[string]ModelSecurityEntry{
|
|
||||||
"pre:0": {APIKeys: []string{"sk-already-plain"}},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("setup SaveConfig: %v", err)
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
|
|
@ -1199,23 +1194,18 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
|
Version: CurrentVersion,
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}},
|
{ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")},
|
||||||
{ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}},
|
{ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)},
|
||||||
{ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}},
|
{ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("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"}},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := SaveConfig(cfgPath, cfg); err != nil {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Logf("alreadyEncrypted: %s", alreadyEncrypted)
|
||||||
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
|
||||||
s := string(raw)
|
s := string(raw)
|
||||||
|
|
||||||
|
|
@ -1265,20 +1255,16 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
|
||||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
|
||||||
mustSetupSSHKey(t)
|
mustSetupSSHKey(t)
|
||||||
if err := SaveConfig(cfgPath, &Config{
|
if err := SaveConfig(cfgPath, &Config{
|
||||||
|
Version: CurrentVersion,
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}},
|
{ModelName: "m", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-secret")},
|
||||||
},
|
|
||||||
security: &SecurityConfig{
|
|
||||||
ModelList: map[string]ModelSecurityEntry{
|
|
||||||
"m:0": {APIKeys: []string{"sk-secret"}},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("setup SaveConfig: %v", err)
|
t.Fatalf("setup SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
raw, err := LoadConfig(cfgPath)
|
raw, err := LoadConfig(cfgPath)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
encValue := raw.security.ModelList["m:0"].APIKeys[0]
|
encValue := raw.ModelList[0].APIKeys[0].raw
|
||||||
assert.NotEmpty(t, encValue)
|
assert.NotEmpty(t, encValue)
|
||||||
assert.Equal(t, "enc://", encValue[:6])
|
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.
|
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
|
||||||
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
||||||
|
|
||||||
_, err = LoadConfig(cfgPath)
|
cfg2, err := LoadConfig(cfgPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
t.Logf("LoadConfig: %#v", cfg2.ModelList)
|
||||||
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "passphrase required") {
|
if !strings.Contains(err.Error(), "passphrase required") {
|
||||||
|
|
@ -1340,9 +1327,8 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
|
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
cfg.ModelList = []*ModelConfig{
|
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 {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig: %v", err)
|
t.Fatalf("SaveConfig: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -1386,6 +1372,8 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
|
||||||
credential.PassphraseProvider = func() string { return testPassphrase }
|
credential.PassphraseProvider = func() string { return testPassphrase }
|
||||||
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
t.Cleanup(func() { credential.PassphraseProvider = orig })
|
||||||
|
|
||||||
|
t.Logf("cfgPath: %s", cfgPath)
|
||||||
|
|
||||||
cfg, err := LoadConfig(cfgPath)
|
cfg, err := LoadConfig(cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig: %v", err)
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
|
@ -1435,17 +1423,15 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
|
||||||
cfgPath := filepath.Join(dir, "config.json")
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
|
Version: CurrentVersion,
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "test-model",
|
ModelName: "test-model",
|
||||||
Model: "openai/test",
|
Model: "openai/test",
|
||||||
apiKeys: []string{"sk-test"},
|
APIKeys: SimpleSecureStrings("sk-test"),
|
||||||
ExtraBody: map[string]any{"custom_field": "value", "num_field": 42},
|
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 {
|
if err := SaveConfig(cfgPath, cfg); err != nil {
|
||||||
|
|
@ -1497,20 +1483,25 @@ func TestFilterSensitiveData(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test with empty content
|
// Test with empty content
|
||||||
cfg.security = &SecurityConfig{}
|
|
||||||
if got := cfg.FilterSensitiveData(""); got != "" {
|
if got := cfg.FilterSensitiveData(""); got != "" {
|
||||||
t.Errorf("empty content: got %q, want empty", got)
|
t.Errorf("empty content: got %q, want empty", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test short content (less than FilterMinLength=8, should skip filtering)
|
// Test short content (less than FilterMinLength=8, should skip filtering)
|
||||||
cfg.security.ModelList = map[string]ModelSecurityEntry{
|
cfg.ModelList = SecureModelList{
|
||||||
"test": {APIKeys: []string{"sk-long-key-12345"}},
|
&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.FilterSensitiveData = true
|
||||||
cfg.Tools.FilterMinLength = 8
|
cfg.Tools.FilterMinLength = 8
|
||||||
|
|
||||||
// Debug: check if sensitive values are collected
|
// Debug: check if sensitive values are collected
|
||||||
values := cfg.security.collectSensitiveValues()
|
values := cfg.collectSensitiveValues()
|
||||||
t.Logf("collected %d sensitive values: %v", len(values), values)
|
t.Logf("collected %d sensitive values: %v", len(values), values)
|
||||||
|
|
||||||
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
|
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
|
||||||
|
|
@ -1538,11 +1529,17 @@ func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
|
||||||
FilterSensitiveData: true,
|
FilterSensitiveData: true,
|
||||||
FilterMinLength: 8,
|
FilterMinLength: 8,
|
||||||
},
|
},
|
||||||
}
|
ModelList: SecureModelList{
|
||||||
cfg.security = &SecurityConfig{
|
&ModelConfig{
|
||||||
ModelList: map[string]ModelSecurityEntry{
|
ModelName: "model1",
|
||||||
"model1": {APIKeys: []string{"key-one", "key-two"}},
|
Model: "openai/model1",
|
||||||
"model2": {APIKeys: []string{"key-three"}},
|
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) {
|
func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
|
||||||
cfg := &Config{
|
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{
|
Tools: ToolsConfig{
|
||||||
FilterSensitiveData: true,
|
FilterSensitiveData: true,
|
||||||
FilterMinLength: 8,
|
FilterMinLength: 8,
|
||||||
},
|
// Web tool API keys
|
||||||
}
|
Web: WebToolsConfig{
|
||||||
cfg.security = &SecurityConfig{
|
Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}},
|
||||||
// Model API keys
|
Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}},
|
||||||
ModelList: map[string]ModelSecurityEntry{
|
Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}},
|
||||||
"test-model": {APIKeys: []string{"sk-model-key-12345"}},
|
GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")},
|
||||||
},
|
BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")},
|
||||||
// Channel tokens
|
},
|
||||||
Channels: &ChannelsSecurity{
|
// Skills tokens
|
||||||
Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"},
|
Skills: SkillsToolsConfig{
|
||||||
Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"},
|
Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")},
|
||||||
Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"},
|
Registries: SkillsRegistriesConfig{
|
||||||
Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"},
|
ClawHub: ClawHubRegistryConfig{AuthToken: *NewSecureString("clawhub-auth-token")},
|
||||||
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: &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"},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,11 +519,5 @@ func DefaultConfig() *Config {
|
||||||
BuildTime: BuildTime,
|
BuildTime: BuildTime,
|
||||||
GoVersion: GoVersion,
|
GoVersion: GoVersion,
|
||||||
},
|
},
|
||||||
security: &SecurityConfig{
|
|
||||||
ModelList: map[string]ModelSecurityEntry{},
|
|
||||||
Channels: &ChannelsSecurity{},
|
|
||||||
Web: &WebToolsSecurity{},
|
|
||||||
Skills: &SkillsSecurity{},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,8 +103,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
|
||||||
if !cfg.Channels.Telegram.Enabled {
|
if !cfg.Channels.Telegram.Enabled {
|
||||||
t.Error("Telegram.Enabled should be true")
|
t.Error("Telegram.Enabled should be true")
|
||||||
}
|
}
|
||||||
if 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(), "test-token")
|
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token.String(), "test-token")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Port != 18790 {
|
if cfg.Gateway.Port != 18790 {
|
||||||
t.Errorf("Gateway.Port = %d, want %d", 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
|
// Verify that the migrated config has the existing security values
|
||||||
// Telegram token should be preserved
|
// 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",
|
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)
|
// 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",
|
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
|
// Model API key should be preserved
|
||||||
|
|
@ -667,17 +667,17 @@ web:
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the security config from disk to verify it wasn't corrupted
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to reload security config: %v", err)
|
t.Fatalf("Failed to reload security config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if reloadedSec.Channels.Telegram == nil ||
|
if reloadedSec.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" {
|
||||||
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
|
|
||||||
t.Error("Telegram token not preserved in .security.yml file")
|
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")
|
t.Error("Discord token not preserved in .security.yml file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,20 +13,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetModelConfig_Found(t *testing.T) {
|
func TestGetModelConfig_Found(t *testing.T) {
|
||||||
cfg := (&Config{
|
cfg := &Config{
|
||||||
Version: CurrentVersion,
|
Version: CurrentVersion,
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "test-model", Model: "openai/gpt-4o"},
|
{ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")},
|
||||||
{ModelName: "other-model", Model: "anthropic/claude"},
|
{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")
|
result, err := cfg.GetModelConfig("test-model")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -38,17 +31,11 @@ func TestGetModelConfig_Found(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelConfig_NotFound(t *testing.T) {
|
func TestGetModelConfig_NotFound(t *testing.T) {
|
||||||
cfg := (&Config{
|
cfg := &Config{
|
||||||
ModelList: []*ModelConfig{
|
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")
|
_, err := cfg.GetModelConfig("nonexistent")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -68,25 +55,13 @@ func TestGetModelConfig_EmptyList(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
func TestGetModelConfig_RoundRobin(t *testing.T) {
|
||||||
cfg := (&Config{
|
cfg := &Config{
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-1"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-2"},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-3"},
|
{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
|
// Test round-robin distribution
|
||||||
results := make(map[string]int)
|
results := make(map[string]int)
|
||||||
|
|
@ -111,9 +86,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
|
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
|
||||||
{ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}},
|
{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) {
|
func TestGetModelConfig_Concurrent(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ModelList: []*ModelConfig{
|
ModelList: []*ModelConfig{
|
||||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
|
||||||
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
|
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
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",
|
ModelName: "glm-4.7",
|
||||||
Model: "zhipu/glm-4.7",
|
Model: "zhipu/glm-4.7",
|
||||||
APIBase: "https://api.example.com",
|
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",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
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",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
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"}
|
modelCfg.Fallbacks = []string{"claude-3"}
|
||||||
models := []*ModelConfig{modelCfg}
|
models := []*ModelConfig{modelCfg}
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
apiKeys: []string{},
|
APIKeys: SimpleSecureStrings(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,7 +164,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
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,
|
RequestTimeout: 30,
|
||||||
ThinkingLevel: "high",
|
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}
|
models := []*ModelConfig{modelCfg}
|
||||||
|
|
||||||
result := expandMultiKeyModels(models)
|
result := expandMultiKeyModels(models)
|
||||||
|
|
@ -237,7 +237,7 @@ func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
|
||||||
{
|
{
|
||||||
ModelName: "gpt-4",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
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",
|
ModelName: "gpt-4",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
apiKeys: []string{"single-key"},
|
APIKeys: SimpleSecureStrings("single-key"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,182 +7,26 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
|
||||||
"github.com/tencent-connect/botgo/log"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SecurityConfigFile = ".security.yml"
|
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
|
// securityPath returns the path to security.yml relative to the config file
|
||||||
func securityPath(configPath string) string {
|
func securityPath(configPath string) string {
|
||||||
configDir := filepath.Dir(configPath)
|
configDir := filepath.Dir(configPath)
|
||||||
|
|
@ -191,34 +35,27 @@ func securityPath(configPath string) string {
|
||||||
|
|
||||||
// loadSecurityConfig loads the security configuration from security.yml
|
// loadSecurityConfig loads the security configuration from security.yml
|
||||||
// Returns an empty SecurityConfig if the file doesn't exist
|
// 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)
|
data, err := os.ReadFile(securityPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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, cfg); err != nil {
|
||||||
if err := yaml.Unmarshal(data, &sec); err != nil {
|
return fmt.Errorf("failed to parse security config: %w", err)
|
||||||
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to validate model_name format here - both formats are supported:
|
return nil
|
||||||
// - "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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSecurityConfig saves the security configuration to security.yml
|
// 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
|
var buf bytes.Buffer
|
||||||
enc := yaml.NewEncoder(&buf)
|
enc := yaml.NewEncoder(&buf)
|
||||||
enc.SetIndent(2)
|
enc.SetIndent(2)
|
||||||
|
|
@ -229,134 +66,6 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
|
||||||
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
|
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 compiled regex for filtering sensitive data.
|
||||||
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
|
||||||
// Computed once on first access via sync.Once.
|
// Computed once on first access via sync.Once.
|
||||||
|
|
@ -367,13 +76,13 @@ type SensitiveDataCache struct {
|
||||||
|
|
||||||
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
|
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
|
||||||
// It is computed once on first access via sync.Once.
|
// It is computed once on first access via sync.Once.
|
||||||
func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer {
|
func (sec *Config) SensitiveDataReplacer() *strings.Replacer {
|
||||||
sec.initSensitiveCache()
|
sec.initSensitiveCache()
|
||||||
return sec.sensitiveCache.replacer
|
return sec.sensitiveCache.replacer
|
||||||
}
|
}
|
||||||
|
|
||||||
// initSensitiveCache initializes the sensitive data cache if not already done.
|
// initSensitiveCache initializes the sensitive data cache if not already done.
|
||||||
func (sec *SecurityConfig) initSensitiveCache() {
|
func (sec *Config) initSensitiveCache() {
|
||||||
if sec.sensitiveCache == nil {
|
if sec.sensitiveCache == nil {
|
||||||
sec.sensitiveCache = &SensitiveDataCache{}
|
sec.sensitiveCache = &SensitiveDataCache{}
|
||||||
}
|
}
|
||||||
|
|
@ -400,15 +109,14 @@ func (sec *SecurityConfig) initSensitiveCache() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
|
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
|
||||||
func (sec *SecurityConfig) collectSensitiveValues() []string {
|
func (sec *Config) collectSensitiveValues() []string {
|
||||||
var values []string
|
var values []string
|
||||||
collectSensitive(reflect.ValueOf(sec), &values)
|
collectSensitive(reflect.ValueOf(sec), &values)
|
||||||
return 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) {
|
func collectSensitive(v reflect.Value, values *[]string) {
|
||||||
// Dereference pointers/interfaces to get the underlying value
|
|
||||||
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
|
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
|
||||||
if v.IsNil() {
|
if v.IsNil() {
|
||||||
return
|
return
|
||||||
|
|
@ -416,27 +124,53 @@ func collectSensitive(v reflect.Value, values *[]string) {
|
||||||
v = v.Elem()
|
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() {
|
switch v.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
for i := 0; i < v.NumField(); i++ {
|
for i := 0; i < v.NumField(); i++ {
|
||||||
field := v.Field(i)
|
if !t.Field(i).IsExported() {
|
||||||
fieldType := v.Type().Field(i)
|
|
||||||
if !fieldType.IsExported() {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
collectSensitive(field, values)
|
collectSensitive(v.Field(i), values)
|
||||||
}
|
|
||||||
case reflect.String:
|
|
||||||
if v.String() != "" {
|
|
||||||
*values = append(*values, v.String())
|
|
||||||
}
|
}
|
||||||
case reflect.Slice:
|
case reflect.Slice:
|
||||||
if v.Type().Elem().Kind() == reflect.String {
|
for i := 0; i < v.Len(); i++ {
|
||||||
for i := 0; i < v.Len(); i++ {
|
collectSensitive(v.Index(i), values)
|
||||||
if s := v.Index(i).String(); s != "" {
|
|
||||||
*values = append(*values, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case reflect.Map:
|
case reflect.Map:
|
||||||
for _, key := range v.MapKeys() {
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
||||||
"web": {
|
"web": {
|
||||||
"brave": {
|
"brave": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"api_key": "BSA-from-config-json-direct"
|
"api_keys": ["BSA-from-config-json-direct"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
|
|
@ -91,11 +91,6 @@ channels:
|
||||||
telegram:
|
telegram:
|
||||||
token: "token-from-security-yml"
|
token: "token-from-security-yml"
|
||||||
|
|
||||||
web:
|
|
||||||
brave:
|
|
||||||
api_keys:
|
|
||||||
- "BSA-from-security-yml"
|
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
github:
|
github:
|
||||||
token: "ghp-from-security-yml"`
|
token: "ghp-from-security-yml"`
|
||||||
|
|
@ -110,16 +105,18 @@ skills:
|
||||||
// Verify model API key from config.json takes precedence
|
// Verify model API key from config.json takes precedence
|
||||||
assert.Equal(t, 1, len(cfg.ModelList))
|
assert.Equal(t, 1, len(cfg.ModelList))
|
||||||
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
|
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
|
// 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
|
// Verify web tool API key from config.json takes precedence
|
||||||
assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey())
|
assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey())
|
||||||
|
|
||||||
// Verify skills token from config.json takes precedence
|
// Verify skills token is resolved
|
||||||
assert.Equal(t, "ghp-from-config-json-direct", cfg.Tools.Skills.Github.token)
|
assert.Equal(t, "ghp-from-security-yml", cfg.Tools.Skills.Github.Token.String())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -355,66 +352,66 @@ skills:
|
||||||
|
|
||||||
// Verify Channel tokens via Key() methods
|
// Verify Channel tokens via Key() methods
|
||||||
// Telegram
|
// Telegram
|
||||||
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token())
|
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String())
|
||||||
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token())
|
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String())
|
||||||
|
|
||||||
// Feishu
|
// Feishu
|
||||||
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret())
|
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String())
|
||||||
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey())
|
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String())
|
||||||
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken())
|
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String())
|
||||||
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret())
|
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String())
|
||||||
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey())
|
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String())
|
||||||
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken())
|
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String())
|
||||||
|
|
||||||
// Discord
|
// Discord
|
||||||
assert.Equal(t, "discord_test_bot_token_xyz", 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())
|
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token.String())
|
||||||
|
|
||||||
// DingTalk
|
// DingTalk
|
||||||
assert.Equal(t, "dingtalk_test_client_secret", 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())
|
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret.String())
|
||||||
|
|
||||||
// Slack
|
// Slack
|
||||||
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken())
|
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())
|
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken.String())
|
||||||
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken())
|
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken.String())
|
||||||
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken())
|
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken.String())
|
||||||
|
|
||||||
// Matrix
|
// Matrix
|
||||||
assert.Equal(t, "matrix_test_access_token", 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())
|
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken.String())
|
||||||
|
|
||||||
// LINE
|
// LINE
|
||||||
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret())
|
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String())
|
||||||
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken())
|
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String())
|
||||||
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret())
|
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String())
|
||||||
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken())
|
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String())
|
||||||
|
|
||||||
// OneBot
|
// OneBot
|
||||||
assert.Equal(t, "onebot_test_access_token", 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())
|
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken.String())
|
||||||
|
|
||||||
// WeCom
|
// WeCom
|
||||||
assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID)
|
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 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
|
// Pico
|
||||||
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
|
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token.String())
|
||||||
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token())
|
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token.String())
|
||||||
|
|
||||||
// IRC
|
// IRC
|
||||||
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password())
|
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password.String())
|
||||||
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword())
|
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword.String())
|
||||||
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword())
|
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword.String())
|
||||||
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password())
|
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password.String())
|
||||||
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword())
|
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword.String())
|
||||||
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword())
|
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword.String())
|
||||||
|
|
||||||
// QQ
|
// QQ
|
||||||
assert.Equal(t, "qq_test_app_secret", 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())
|
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret.String())
|
||||||
|
|
||||||
// Verify Web tool API keys
|
// Verify Web tool API keys
|
||||||
assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
|
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())
|
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
|
||||||
|
|
||||||
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
|
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
|
||||||
t.Logf("GLMSearch APIKey(): %s", 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())
|
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String())
|
||||||
|
|
||||||
// Verify Skills tokens
|
// Verify Skills tokens
|
||||||
assert.Equal(t, "ghp-github-from-file-abc123", 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())
|
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())
|
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())
|
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")
|
t.Log("All security keys are successfully accessible via their respective Key() methods")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,23 +6,29 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/caarlos0/env/v11"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/credential"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSecurityConfig(t *testing.T) {
|
func TestSecurityConfig(t *testing.T) {
|
||||||
t.Run("LoadNonExistent", func(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)
|
require.NoError(t, err)
|
||||||
assert.NotNil(t, sec)
|
assert.NotNil(t, sec)
|
||||||
assert.Empty(t, sec.ModelList)
|
assert.Empty(t, sec.ModelList)
|
||||||
assert.NotNil(t, sec.Channels)
|
assert.NotNil(t, sec.Channels)
|
||||||
assert.NotNil(t, sec.Web)
|
assert.NotNil(t, sec.Tools.Web)
|
||||||
assert.NotNil(t, sec.Skills)
|
assert.NotNil(t, sec.Tools.Skills)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,41 +59,302 @@ func TestSecurityPath(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSaveAndLoadSecurityConfig(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()
|
tmpDir := t.TempDir()
|
||||||
secPath := filepath.Join(tmpDir, SecurityConfigFile)
|
secPath := filepath.Join(tmpDir, SecurityConfigFile)
|
||||||
|
|
||||||
original := &SecurityConfig{
|
original := &Config{
|
||||||
ModelList: map[string]ModelSecurityEntry{
|
ModelList: SecureModelList{
|
||||||
"model1:0": {
|
{
|
||||||
APIKeys: []string{"key1", "key2"},
|
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{
|
Tools: ToolsConfig{
|
||||||
Telegram: &TelegramSecurity{
|
Web: WebToolsConfig{
|
||||||
Token: "telegram-token",
|
Brave: BraveConfig{
|
||||||
|
Enabled: true,
|
||||||
|
APIKeys: SecureStrings{NewSecureString("brave_key")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Skills: SkillsToolsConfig{
|
||||||
|
Github: SkillsGithubConfig{
|
||||||
|
Token: *NewSecureString("github_token"),
|
||||||
|
Proxy: "test proxy",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Web: &WebToolsSecurity{
|
Channels: ChannelsConfig{
|
||||||
Brave: &BraveSecurity{
|
Telegram: TelegramConfig{
|
||||||
APIKeys: []string{"brave-api-key"},
|
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
|
t.Run("test for original", func(t *testing.T) {
|
||||||
err := saveSecurityConfig(secPath, original)
|
assert.Equal(t, 2, len(original.ModelList[0].APIKeys))
|
||||||
require.NoError(t, err)
|
assert.Equal(t, "key1", original.ModelList[0].APIKeys[0].String())
|
||||||
|
})
|
||||||
|
|
||||||
// Verify file was created with correct permissions
|
cfg2 := &Config{}
|
||||||
info, err := os.Stat(secPath)
|
t.Run("test for json", func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
marshal, err := json.Marshal(original)
|
||||||
assert.Equal(t, os.FileMode(0o600), info.Mode())
|
require.NoError(t, err)
|
||||||
|
t.Logf("json: %s", string(marshal))
|
||||||
|
assert.Contains(t, string(marshal), "\"api_keys\"")
|
||||||
|
assert.Contains(t, string(marshal), notHere)
|
||||||
|
|
||||||
// Load
|
err = json.Unmarshal(marshal, cfg2)
|
||||||
loaded, err := loadSecurityConfig(secPath)
|
require.NoError(t, err)
|
||||||
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)
|
t.Run("test for save yaml", func(t *testing.T) {
|
||||||
assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token)
|
// Save
|
||||||
assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys)
|
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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,13 @@ const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH"
|
||||||
const picoclawHome = "PICOCLAW_HOME"
|
const picoclawHome = "PICOCLAW_HOME"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fileScheme = "file://"
|
FileScheme = "file://"
|
||||||
encScheme = "enc://"
|
EncScheme = "enc://"
|
||||||
hkdfInfo = "picoclaw-credential-v1"
|
|
||||||
saltLen = 16
|
hkdfInfo = "picoclaw-credential-v1"
|
||||||
nonceLen = 12
|
saltLen = 16
|
||||||
keyLen = 32
|
nonceLen = 12
|
||||||
|
keyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
// Resolver resolves raw credential strings for model_list api_key fields.
|
// 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
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(raw, fileScheme) {
|
if strings.HasPrefix(raw, FileScheme) {
|
||||||
fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme))
|
fileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme))
|
||||||
if fileName == "" {
|
if fileName == "" {
|
||||||
return "", fmt.Errorf("credential: file:// reference has no 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
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(raw, encScheme) {
|
if strings.HasPrefix(raw, EncScheme) {
|
||||||
return resolveEncrypted(raw)
|
return resolveEncrypted(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ func resolveEncrypted(raw string) (string, error) {
|
||||||
|
|
||||||
sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect
|
sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect
|
||||||
|
|
||||||
b64 := strings.TrimPrefix(raw, encScheme)
|
b64 := strings.TrimPrefix(raw, EncScheme)
|
||||||
blob, err := base64.StdEncoding.DecodeString(b64)
|
blob, err := base64.StdEncoding.DecodeString(b64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("credential: enc:// invalid base64: %w", err)
|
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, salt...)
|
||||||
blob = append(blob, nonce...)
|
blob = append(blob, nonce...)
|
||||||
blob = append(blob, ciphertext...)
|
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.
|
// isWithinDir reports whether path is contained within (or equal to) dir.
|
||||||
|
|
|
||||||
|
|
@ -1029,7 +1029,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
Proxy: c.Telegram.Proxy,
|
Proxy: c.Telegram.Proxy,
|
||||||
}
|
}
|
||||||
if c.Telegram.Token != "" {
|
if c.Telegram.Token != "" {
|
||||||
tc.SetToken(c.Telegram.Token)
|
tc.Token = *config.NewSecureString(c.Telegram.Token)
|
||||||
}
|
}
|
||||||
return tc
|
return tc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1039,13 +1039,13 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
AppID: c.Feishu.AppID,
|
AppID: c.Feishu.AppID,
|
||||||
}
|
}
|
||||||
if c.Feishu.AppSecret != "" {
|
if c.Feishu.AppSecret != "" {
|
||||||
fc.SetAppSecret(c.Feishu.AppSecret)
|
fc.AppSecret = *config.NewSecureString(c.Feishu.AppSecret)
|
||||||
}
|
}
|
||||||
if c.Feishu.EncryptKey != "" {
|
if c.Feishu.EncryptKey != "" {
|
||||||
fc.SetEncryptKey(c.Feishu.EncryptKey)
|
fc.EncryptKey = *config.NewSecureString(c.Feishu.EncryptKey)
|
||||||
}
|
}
|
||||||
if c.Feishu.VerificationToken != "" {
|
if c.Feishu.VerificationToken != "" {
|
||||||
fc.SetVerificationToken(c.Feishu.VerificationToken)
|
fc.VerificationToken = *config.NewSecureString(c.Feishu.VerificationToken)
|
||||||
}
|
}
|
||||||
return fc
|
return fc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1055,7 +1055,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
MentionOnly: c.Discord.MentionOnly,
|
MentionOnly: c.Discord.MentionOnly,
|
||||||
}
|
}
|
||||||
if c.Discord.Token != "" {
|
if c.Discord.Token != "" {
|
||||||
dc.SetToken(c.Discord.Token)
|
dc.Token = *config.NewSecureString(c.Discord.Token)
|
||||||
}
|
}
|
||||||
return dc
|
return dc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1070,7 +1070,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
AppID: c.QQ.AppID,
|
AppID: c.QQ.AppID,
|
||||||
}
|
}
|
||||||
if c.QQ.AppSecret != "" {
|
if c.QQ.AppSecret != "" {
|
||||||
qc.SetAppSecret(c.QQ.AppSecret)
|
qc.AppSecret = *config.NewSecureString(c.QQ.AppSecret)
|
||||||
}
|
}
|
||||||
return qc
|
return qc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1080,7 +1080,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
ClientID: c.DingTalk.ClientID,
|
ClientID: c.DingTalk.ClientID,
|
||||||
}
|
}
|
||||||
if c.DingTalk.ClientSecret != "" {
|
if c.DingTalk.ClientSecret != "" {
|
||||||
dt.SetClientSecret(c.DingTalk.ClientSecret)
|
dt.ClientSecret = *config.NewSecureString(c.DingTalk.ClientSecret)
|
||||||
}
|
}
|
||||||
return dt
|
return dt
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1089,10 +1089,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
Enabled: c.Slack.Enabled,
|
Enabled: c.Slack.Enabled,
|
||||||
}
|
}
|
||||||
if c.Slack.BotToken != "" {
|
if c.Slack.BotToken != "" {
|
||||||
sc.SetBotToken(c.Slack.BotToken)
|
sc.BotToken = *config.NewSecureString(c.Slack.BotToken)
|
||||||
}
|
}
|
||||||
if c.Slack.AppToken != "" {
|
if c.Slack.AppToken != "" {
|
||||||
sc.SetAppToken(c.Slack.AppToken)
|
sc.AppToken = *config.NewSecureString(c.Slack.AppToken)
|
||||||
}
|
}
|
||||||
return sc
|
return sc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1105,7 +1105,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
JoinOnInvite: true,
|
JoinOnInvite: true,
|
||||||
}
|
}
|
||||||
if c.Matrix.AccessToken != "" {
|
if c.Matrix.AccessToken != "" {
|
||||||
mc.SetAccessToken(c.Matrix.AccessToken)
|
mc.AccessToken = *config.NewSecureString(c.Matrix.AccessToken)
|
||||||
}
|
}
|
||||||
return mc
|
return mc
|
||||||
}(),
|
}(),
|
||||||
|
|
@ -1117,10 +1117,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
|
||||||
WebhookPath: c.LINE.WebhookPath,
|
WebhookPath: c.LINE.WebhookPath,
|
||||||
}
|
}
|
||||||
if c.LINE.ChannelSecret != "" {
|
if c.LINE.ChannelSecret != "" {
|
||||||
lc.SetChannelSecret(c.LINE.ChannelSecret)
|
lc.ChannelSecret = *config.NewSecureString(c.LINE.ChannelSecret)
|
||||||
}
|
}
|
||||||
if c.LINE.ChannelAccessToken != "" {
|
if c.LINE.ChannelAccessToken != "" {
|
||||||
lc.SetChannelAccessToken(c.LINE.ChannelAccessToken)
|
lc.ChannelAccessToken = *config.NewSecureString(c.LINE.ChannelAccessToken)
|
||||||
}
|
}
|
||||||
return lc
|
return lc
|
||||||
}(),
|
}(),
|
||||||
|
|
|
||||||
|
|
@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) {
|
||||||
if !stdCfg.Channels.Telegram.Enabled {
|
if !stdCfg.Channels.Telegram.Enabled {
|
||||||
t.Error("telegram should be enabled")
|
t.Error("telegram should be enabled")
|
||||||
}
|
}
|
||||||
if stdCfg.Channels.Telegram.Token() != "test-token" {
|
if stdCfg.Channels.Telegram.Token.String() != "test-token" {
|
||||||
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token())
|
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if stdCfg.Gateway.Port != 8080 {
|
if stdCfg.Gateway.Port != 8080 {
|
||||||
|
|
|
||||||
|
|
@ -20,89 +20,72 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice model name selects audio model transcriber",
|
name: "voice model name selects audio model transcriber",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{ModelName: "voice-gemini"},
|
Voice: config.VoiceConfig{ModelName: "voice-gemini"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
|
{
|
||||||
},
|
ModelName: "voice-gemini",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "gemini/gemini-2.5-flash",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-gemini-model"),
|
||||||
"voice-gemini": {
|
|
||||||
APIKeys: []string{"sk-gemini-model"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "audio-model",
|
wantName: "audio-model",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "groq via model list",
|
name: "groq via model list",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "openai", Model: "openai/gpt-4o"},
|
{ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")},
|
||||||
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
{
|
||||||
},
|
ModelName: "groq",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "groq/llama-3.3-70b",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
|
||||||
"openai": {
|
|
||||||
APIKeys: []string{"sk-openai"},
|
|
||||||
},
|
|
||||||
"groq": {
|
|
||||||
APIKeys: []string{"sk-groq-model"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "groq",
|
wantName: "groq",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice model name selects non-gemini audio model transcriber",
|
name: "voice model name selects non-gemini audio model transcriber",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{ModelName: "voice-openai-audio"},
|
Voice: config.VoiceConfig{ModelName: "voice-openai-audio"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"},
|
{
|
||||||
},
|
ModelName: "voice-openai-audio",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "openai/gpt-4o-audio-preview",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-openai"),
|
||||||
"voice-openai-audio": {
|
|
||||||
APIKeys: []string{"sk-openai"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "audio-model",
|
wantName: "audio-model",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice model name selects azure audio model transcriber",
|
name: "voice model name selects azure audio model transcriber",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{ModelName: "voice-azure-audio"},
|
Voice: config.VoiceConfig{ModelName: "voice-azure-audio"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{
|
{
|
||||||
ModelName: "voice-azure-audio",
|
ModelName: "voice-azure-audio",
|
||||||
Model: "azure/my-audio-deployment",
|
Model: "azure/my-audio-deployment", APIKeys: config.SimpleSecureStrings("sk-azure"),
|
||||||
APIBase: "https://example.openai.azure.com",
|
APIBase: "https://example.openai.azure.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
},
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
|
||||||
"voice-azure-audio": {
|
|
||||||
APIKeys: []string{"sk-azure"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
wantName: "audio-model",
|
wantName: "audio-model",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice model name with non openai compatible protocol does not select audio model transcriber",
|
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"},
|
Voice: config.VoiceConfig{ModelName: "voice-anthropic"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"},
|
{
|
||||||
},
|
ModelName: "voice-anthropic",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "anthropic/claude-sonnet-4.6",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-anthropic"),
|
||||||
"voice-anthropic": {
|
|
||||||
APIKeys: []string{"sk-anthropic"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantNil: true,
|
wantNil: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -116,33 +99,29 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "provider key takes priority over model list",
|
name: "provider key takes priority over model list",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
{
|
||||||
},
|
ModelName: "groq",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "groq/llama-3.3-70b",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
|
||||||
"groq": {
|
|
||||||
APIKeys: []string{"sk-groq-model"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "groq",
|
wantName: "groq",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing voice model name config returns nil",
|
name: "missing voice model name config returns nil",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{ModelName: "missing"},
|
Voice: config.VoiceConfig{ModelName: "missing"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "other", Model: "gemini/gemini-2.5-flash"},
|
{
|
||||||
},
|
ModelName: "other",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "gemini/gemini-2.5-flash",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-other-model"),
|
||||||
"other": {
|
|
||||||
APIKeys: []string{"sk-other-model"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantNil: true,
|
wantNil: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -154,37 +133,33 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "elevenlabs takes priority over groq model list",
|
name: "elevenlabs takes priority over groq model list",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
|
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
|
{
|
||||||
},
|
ModelName: "groq",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "groq/llama-3.3-70b",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
|
||||||
"groq": {
|
|
||||||
APIKeys: []string{"sk-groq-direct"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "elevenlabs",
|
wantName: "elevenlabs",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "voice model name takes priority over elevenlabs",
|
name: "voice model name takes priority over elevenlabs",
|
||||||
cfg: (&config.Config{
|
cfg: &config.Config{
|
||||||
Voice: config.VoiceConfig{
|
Voice: config.VoiceConfig{
|
||||||
ModelName: "voice-gemini",
|
ModelName: "voice-gemini",
|
||||||
ElevenLabsAPIKey: "sk_elevenlabs_test",
|
ElevenLabsAPIKey: "sk_elevenlabs_test",
|
||||||
},
|
},
|
||||||
ModelList: []*config.ModelConfig{
|
ModelList: []*config.ModelConfig{
|
||||||
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
|
{
|
||||||
},
|
ModelName: "voice-gemini",
|
||||||
}).WithSecurity(&config.SecurityConfig{
|
Model: "gemini/gemini-2.5-flash",
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
APIKeys: config.SimpleSecureStrings("sk-gemini-model"),
|
||||||
"voice-gemini": {
|
|
||||||
APIKeys: []string{"sk-gemini-model"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
wantName: "audio-model",
|
wantName: "audio-model",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Load existing config and copy security credentials before validation,
|
// Load existing config and copy security credentials before validation,
|
||||||
// so that security-managed fields (e.g. pico token) are available.
|
// 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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
cfg.SecurityCopyFrom(oldCfg)
|
|
||||||
|
|
||||||
if errs := validateConfig(&cfg); len(errs) > 0 {
|
if errs := validateConfig(&cfg); len(errs) > 0 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
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)
|
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore security fields (tokens/keys) from the loaded config before validation,
|
// Restore security fields (tokens/keys) from the loaded config before validation,
|
||||||
// because private fields are lost during JSON round-trip.
|
// because private fields are lost during JSON round-trip.
|
||||||
newCfg.SecurityCopyFrom(cfg)
|
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
|
||||||
if err := newCfg.ApplySecurity(); err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -261,17 +259,17 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pico channel: token required when enabled
|
// 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")
|
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram: token required when 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")
|
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord: token required when 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")
|
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 == "" {
|
if cfg.Channels.WeCom.BotID == "" {
|
||||||
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
|
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")
|
errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,17 +163,11 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) {
|
||||||
cfg.ModelList = []*config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "custom-default",
|
ModelName: "custom-default",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
|
APIKeys: config.SimpleSecureStrings("sk-default"),
|
||||||
}}
|
}}
|
||||||
cfg.Agents.Defaults.ModelName = "custom-default"
|
cfg.Agents.Defaults.ModelName = "custom-default"
|
||||||
cfg.Channels.Pico.Enabled = true
|
cfg.Channels.Pico.Enabled = true
|
||||||
cfg.WithSecurity(&config.SecurityConfig{
|
cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token")
|
||||||
ModelList: map[string]config.ModelSecurityEntry{
|
|
||||||
"custom-default": {APIKeys: []string{"sk-default"}},
|
|
||||||
},
|
|
||||||
Channels: &config.ChannelsSecurity{
|
|
||||||
Pico: &config.PicoSecurity{Token: "test-pico-token"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
configPath := filepath.Join(tmp, "config.json")
|
configPath := filepath.Join(tmp, "config.json")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
ModelName: "vllm-remote",
|
ModelName: "vllm-remote",
|
||||||
Model: "vllm/custom-model",
|
Model: "vllm/custom-model",
|
||||||
APIBase: "https://models.example.com/v1",
|
APIBase: "https://models.example.com/v1",
|
||||||
|
APIKeys: config.SimpleSecureStrings("remote-key"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ModelName: "copilot-gpt-5.4",
|
ModelName: "copilot-gpt-5.4",
|
||||||
|
|
@ -88,11 +89,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
|
||||||
AuthMethod: "oauth",
|
AuthMethod: "oauth",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
|
|
||||||
"vllm-remote": {
|
|
||||||
APIKeys: []string{"remote-key"},
|
|
||||||
},
|
|
||||||
}})
|
|
||||||
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
t.Fatalf("SaveConfig() error = %v", err)
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
|
|
||||||
|
|
@ -232,15 +232,9 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) {
|
||||||
cfg.ModelList = []*config.ModelConfig{{
|
cfg.ModelList = []*config.ModelConfig{{
|
||||||
ModelName: "custom-default",
|
ModelName: "custom-default",
|
||||||
Model: "openai/gpt-4o",
|
Model: "openai/gpt-4o",
|
||||||
|
APIKeys: config.SimpleSecureStrings("sk-default"),
|
||||||
}}
|
}}
|
||||||
cfg.Agents.Defaults.ModelName = "custom-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")
|
configPath := filepath.Join(tmp, "config.json")
|
||||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"token": cfg.Channels.Pico.Token(),
|
"token": cfg.Channels.Pico.Token.String(),
|
||||||
"ws_url": wsURL,
|
"ws_url": wsURL,
|
||||||
"enabled": cfg.Channels.Pico.Enabled,
|
"enabled": cfg.Channels.Pico.Enabled,
|
||||||
})
|
})
|
||||||
|
|
@ -110,7 +110,7 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Channels.Pico.Token() == "" {
|
if cfg.Channels.Pico.Token.String() == "" {
|
||||||
cfg.Channels.Pico.SetToken(generateSecureToken())
|
cfg.Channels.Pico.SetToken(generateSecureToken())
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"token": cfg.Channels.Pico.Token(),
|
"token": cfg.Channels.Pico.Token.String(),
|
||||||
"ws_url": wsURL,
|
"ws_url": wsURL,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
|
||||||
if !cfg.Channels.Pico.Enabled {
|
if !cfg.Channels.Pico.Enabled {
|
||||||
t.Error("expected Pico to be enabled after setup")
|
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")
|
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)
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if 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(), "user-custom-token")
|
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token")
|
||||||
}
|
}
|
||||||
if !cfg.Channels.Pico.AllowTokenQuery {
|
if !cfg.Channels.Pico.AllowTokenQuery {
|
||||||
t.Error("user's allow_token_query=true must be preserved")
|
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 {
|
if !cfg.Channels.Pico.Enabled {
|
||||||
t.Error("expected Pico to be enabled after setup")
|
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")
|
t.Error("expected a non-empty token after setup")
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil {
|
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 {
|
if !cfg.Channels.Pico.Enabled {
|
||||||
t.Error("expected Pico to be enabled after launcher startup setup")
|
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")
|
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)
|
cfg1, _ := config.LoadConfig(configPath)
|
||||||
token1 := cfg1.Channels.Pico.Token()
|
token1 := cfg1.Channels.Pico.Token.String()
|
||||||
|
|
||||||
// Second call should be a no-op
|
// Second call should be a no-op
|
||||||
changed, err := h.EnsurePicoChannel(origin)
|
changed, err := h.EnsurePicoChannel(origin)
|
||||||
|
|
@ -244,7 +244,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg2, _ := config.LoadConfig(configPath)
|
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")
|
t.Error("token should not change on subsequent calls")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadConfig() error = %v", err)
|
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")
|
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
|
||||||
}
|
}
|
||||||
if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
|
if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue