Merge branch 'main' of https://github.com/sipeed/picoclaw into chore-azure-openai-response

This commit is contained in:
Kunal Karmakar 2026-03-27 16:22:50 +00:00
commit 3f9c31fd55
52 changed files with 2124 additions and 2430 deletions

View file

@ -121,7 +121,7 @@ func TestApplyWeComAuthResult(t *testing.T) {
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
}
@ -151,7 +151,7 @@ func TestAuthWeComCmdWithScanner(t *testing.T) {
require.NoError(t, err)
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret())
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
assert.Contains(t, output.String(), "WeCom connected.")
}

View file

@ -58,24 +58,21 @@ func TestNewModelCommand(t *testing.T) {
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4"},
{ModelName: "claude-3", Model: "anthropic/claude-3"},
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"gpt-4": {
APIKeys: []string{"test"},
},
"claude-3": {
APIKeys: []string{"test"},
},
}})
}
output := captureStdout(func() {
showCurrentModel(cfg)
@ -88,20 +85,16 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4"},
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"gpt-4": {
APIKeys: []string{"test"},
},
}})
}
output := captureStdout(func() {
showCurrentModel(cfg)
@ -124,25 +117,22 @@ func TestListAvailableModels_Empty(t *testing.T) {
}
func TestListAvailableModels_WithModels(t *testing.T) {
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4"},
{ModelName: "claude-3", Model: "anthropic/claude-3"},
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{ModelName: "no-key-model", Model: "openai/test"},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"gpt-4": {
APIKeys: []string{"test"},
},
"claude-3": {
APIKeys: []string{"test"},
},
}})
}
output := captureStdout(func() {
listAvailableModels(cfg)
@ -157,24 +147,25 @@ func TestListAvailableModels_WithModels(t *testing.T) {
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model"},
{ModelName: "old-model", Model: "openai/old-model"},
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{
ModelName: "old-model",
Model: "openai/old-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"new-model": {
APIKeys: []string{"test"},
},
"old-model": {
APIKeys: []string{"test"},
},
}})
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
@ -192,20 +183,20 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing"},
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"existing-model": {
APIKeys: []string{"test"},
},
}})
}
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
@ -213,24 +204,21 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing"},
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{ModelName: "no-key-model", Model: "openai/nokey"},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"existing-model": {
APIKeys: []string{"test"},
},
"no-key-model": {
APIKeys: []string{""},
},
}})
}
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
@ -239,20 +227,20 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model"},
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"new-model": {
APIKeys: []string{"test"},
},
}})
}
err := setDefaultModel(invalidPath, cfg, "new-model")
@ -284,20 +272,20 @@ func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "test-model", Model: "openai/test"},
{
ModelName: "test-model",
Model: "openai/test",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"test-model": {
APIKeys: []string{"test"},
},
}})
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
@ -315,25 +303,25 @@ func TestModelCommandExecution_Show(t *testing.T) {
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
sec := &config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"old-model": {
APIKeys: []string{"test"},
},
"new-model": {
APIKeys: []string{"test"},
},
}}
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "old-model", Model: "openai/old"},
{ModelName: "new-model", Model: "openai/new"},
{
ModelName: "old-model",
Model: "openai/old",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{
ModelName: "new-model",
Model: "openai/new",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(sec)
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
@ -357,28 +345,30 @@ func TestModelCommandExecution_TooManyArgs(t *testing.T) {
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
cfg := (&config.Config{
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
ModelList: []*config.ModelConfig{
{ModelName: "first-model", Model: "openai/first"},
{ModelName: "middle-model", Model: "openai/middle"},
{ModelName: "last-model", Model: "openai/last"},
{
ModelName: "first-model",
Model: "openai/first",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{
ModelName: "middle-model",
Model: "openai/middle",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
{
ModelName: "last-model",
Model: "openai/last",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
},
},
}).WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"first-model": {
APIKeys: []string{"test"},
},
"middle-model": {
APIKeys: []string{"test"},
},
"last-model": {
APIKeys: []string{"test"},
},
}})
}
output := captureStdout(func() {
listAvailableModels(cfg)

View file

@ -31,7 +31,7 @@ func NewSkillsCommand() *cobra.Command {
d.workspace = cfg.WorkspacePath()
installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token(),
cfg.Tools.Skills.Github.Token.String(),
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {

View file

@ -70,7 +70,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken(),
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
@ -243,7 +243,7 @@ func skillsSearchCmd(query string) {
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken(),
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,

View file

@ -70,12 +70,12 @@ General settings for fetching and processing webpage content.
Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
| Config | Type | Default | Description |
|---------------|--------|------------------------------------------------------------------|---------------------------|
| `enabled` | bool | false | Enable Baidu Search |
| `api_key` | string | - | Qianfan API key |
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
| `max_results` | int | 10 | Maximum number of results |
| Config | Type | Default | Description |
|---------------|--------|--------------------------------------------------------|---------------------------|
| `enabled` | bool | false | Enable Baidu Search |
| `api_key` | string | - | Qianfan API key |
| `base_url` | string | `https://qianfan.baidubce.com/v2/ai_search/web_search` | Baidu Search API URL |
| `max_results` | int | 5 | Maximum number of results |
```json
{
@ -107,25 +107,25 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
| `enabled` | bool | false | Enable Tavily search |
| `api_key` | string | - | Tavily API key |
| `base_url` | string | - | Custom Tavily API base URL |
| `max_results` | int | 0 | Maximum number of results (0 = default) |
| `max_results` | int | 5 | Maximum number of results |
### SearXNG
| Config | Type | Default | Description |
|---------------|--------|--------------------------|---------------------------|
| `enabled` | bool | false | Enable SearXNG search |
| `base_url` | string | `http://localhost:8888` | SearXNG instance URL |
| `max_results` | int | 5 | Maximum number of results |
| Config | Type | Default | Description |
|---------------|--------|-------------------------|---------------------------|
| `enabled` | bool | false | Enable SearXNG search |
| `base_url` | string | `http://localhost:8888` | SearXNG instance URL |
| `max_results` | int | 5 | Maximum number of results |
### GLM Search
| Config | Type | Default | Description |
|-----------------|--------|------------------------------------------------------|---------------------------|
| `enabled` | bool | false | Enable GLM Search |
| `api_key` | string | - | GLM API key |
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
| `search_engine` | string | `search_std` | Search engine type |
| `max_results` | int | 5 | Maximum number of results |
| Config | Type | Default | Description |
|-----------------|--------|---------------------------------------------------|---------------------------|
| `enabled` | bool | false | Enable GLM Search |
| `api_key` | string | - | GLM API key |
| `base_url` | string | `https://open.bigmodel.cn/api/paas/v4/web_search` | GLM Search API URL |
| `search_engine` | string | `search_std` | Search engine type |
| `max_results` | int | 5 | Maximum number of results |
### Additional Web Settings
@ -134,6 +134,28 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa
| `prefer_native` | bool | true | Prefer provider's native search over configured search engines |
| `private_host_whitelist` | string[] | `[]` | Private/internal hosts allowed for web fetching |
### `web_search` Tool Parameters
At runtime, the `web_search` tool accepts the following parameters:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `query` | string | yes | Search query string |
| `count` | integer | no | Number of results to return. Default: `10`, max: `10` |
| `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) |
If `range` is omitted, PicoClaw performs an unrestricted search.
### Example `web_search` Call
```json
{
"query": "ai agent news",
"count": 10,
"range": "w"
}
```
## Exec Tool
The exec tool is used to execute shell commands.

View file

@ -165,33 +165,27 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("web") {
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey(), cfg.Tools.Web.Brave.APIKeys()),
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
TavilyAPIKeys: config.MergeAPIKeys(
cfg.Tools.Web.Tavily.APIKey(),
cfg.Tools.Web.Tavily.APIKeys(),
),
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKeys: config.MergeAPIKeys(
cfg.Tools.Web.Perplexity.APIKey(),
cfg.Tools.Web.Perplexity.APIKeys(),
),
BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(),
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults,
SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled,
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey(),
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(),
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey(),
BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(),
BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL,
BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults,
BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled,
@ -263,7 +257,7 @@ func registerSharedTools(
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken(),
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,

View file

@ -1467,24 +1467,16 @@ func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
ModelName: "local",
Model: "openai/local-model",
APIBase: "https://local.example.invalid/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
},
{
ModelName: "deepseek",
Model: "openrouter/deepseek/deepseek-v3.2",
APIBase: "https://openrouter.ai/api/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
},
},
}
cfg.WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"local": {
APIKeys: []string{"test-key"},
},
"deepseek": {
APIKeys: []string{"test-key"},
},
},
})
msgBus := bus.NewMessageBus()
provider := &countingMockProvider{response: "LLM reply"}
@ -1546,16 +1538,10 @@ func TestProcessMessage_SwitchModelRejectsUnknownAlias(t *testing.T) {
ModelName: "local",
Model: "openai/local-model",
APIBase: "https://local.example.invalid/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
},
},
}
cfg.WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"local": {
APIKeys: []string{"test-key"},
},
},
})
msgBus := bus.NewMessageBus()
provider := &countingMockProvider{response: "LLM reply"}
@ -1627,24 +1613,16 @@ func TestProcessMessage_SwitchModelRoutesSubsequentRequestsToSelectedProvider(t
ModelName: "local",
Model: "openai/Qwen3.5-35B-A3B",
APIBase: localServer.URL,
APIKeys: config.SimpleSecureStrings("local-key"),
},
{
ModelName: "deepseek",
Model: "openrouter/deepseek/deepseek-v3.2",
APIBase: remoteServer.URL,
APIKeys: config.SimpleSecureStrings("remote-key"),
},
},
}
cfg.WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"local": {
APIKeys: []string{"local-key"},
},
"deepseek": {
APIKeys: []string{"remote-key"},
},
},
})
msgBus := bus.NewMessageBus()
provider, _, err := providers.CreateProvider(cfg)

View file

@ -36,7 +36,7 @@ type DingTalkChannel struct {
// NewDingTalkChannel creates a new DingTalk channel instance
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
if cfg.ClientID == "" || cfg.ClientSecret() == "" {
if cfg.ClientID == "" || cfg.ClientSecret.String() == "" {
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
@ -53,7 +53,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
BaseChannel: base,
config: cfg,
clientID: cfg.ClientID,
clientSecret: cfg.ClientSecret(),
clientSecret: cfg.ClientSecret.String(),
}, nil
}

View file

@ -53,7 +53,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token())
session, err := discordgo.New("Bot " + cfg.Token.String())
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
}

View file

@ -63,14 +63,14 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
BaseChannel: base,
config: cfg,
tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret(), opts...),
client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...),
}
ch.SetOwner(ch)
return ch, nil
}
func (c *FeishuChannel) Start(ctx context.Context) error {
if c.config.AppID == "" || c.config.AppSecret() == "" {
if c.config.AppID == "" || c.config.AppSecret.String() == "" {
return fmt.Errorf("feishu app_id or app_secret is empty")
}
@ -81,7 +81,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
})
}
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken(), c.config.EncryptKey()).
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken.String(), c.config.EncryptKey.String()).
OnP2MessageReceiveV1(c.handleMessageReceive)
runCtx, cancel := context.WithCancel(ctx)
@ -94,7 +94,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
}
c.wsClient = larkws.NewClient(
c.config.AppID,
c.config.AppSecret(),
c.config.AppSecret.String(),
larkws.WithEventHandler(dispatcher),
larkws.WithDomain(domain),
)

View file

@ -17,8 +17,8 @@ import (
// onConnect is called after a successful connection (and on reconnect).
func (c *IRCChannel) onConnect(conn *ircevent.Connection) {
// NickServ auth (only if SASL is not configured)
if c.config.NickServPassword() != "" && c.config.SASLUser == "" {
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword())
if c.config.NickServPassword.String() != "" && c.config.SASLUser == "" {
conn.Privmsg("NickServ", "IDENTIFY "+c.config.NickServPassword.String())
}
// Join configured channels

View file

@ -68,7 +68,7 @@ func (c *IRCChannel) Start(ctx context.Context) error {
Nick: c.config.Nick,
User: user,
RealName: realName,
Password: c.config.Password(),
Password: c.config.Password.String(),
UseTLS: c.config.TLS,
RequestCaps: caps,
QuitMessage: "Goodbye",
@ -83,9 +83,9 @@ func (c *IRCChannel) Start(ctx context.Context) error {
}
// SASL auth (takes priority over NickServ)
if c.config.SASLUser != "" && c.config.SASLPassword() != "" {
if c.config.SASLUser != "" && c.config.SASLPassword.String() != "" {
conn.SASLLogin = c.config.SASLUser
conn.SASLPassword = c.config.SASLPassword()
conn.SASLPassword = c.config.SASLPassword.String()
}
// Register event handlers

View file

@ -62,7 +62,7 @@ type LINEChannel struct {
// NewLINEChannel creates a new LINE channel instance.
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
if cfg.ChannelSecret() == "" || cfg.ChannelAccessToken() == "" {
if cfg.ChannelSecret.String() == "" || cfg.ChannelAccessToken.String() == "" {
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
@ -110,7 +110,7 @@ func (c *LINEChannel) fetchBotInfo() error {
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.infoClient.Do(req)
if err != nil {
@ -216,7 +216,7 @@ func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
return false
}
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret()))
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String()))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
@ -655,7 +655,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken())
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.apiClient.Do(req)
if err != nil {
@ -680,7 +680,7 @@ func (c *LINEChannel) downloadContent(messageID, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "line",
ExtraHeaders: map[string]string{
"Authorization": "Bearer " + c.config.ChannelAccessToken(),
"Authorization": "Bearer " + c.config.ChannelAccessToken.String(),
},
})
}

View file

@ -353,7 +353,7 @@ func (m *Manager) initChannel(name, displayName string) {
func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
logger.InfoC("channels", "Initializing channel manager")
if channels.Telegram.Enabled && channels.Telegram.Token() != "" {
if channels.Telegram.Enabled && channels.Telegram.Token.String() != "" {
m.initChannel("telegram", "Telegram")
}
@ -370,7 +370,7 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("feishu", "Feishu")
}
if channels.Discord.Enabled && channels.Discord.Token() != "" {
if channels.Discord.Enabled && channels.Discord.Token.String() != "" {
m.initChannel("discord", "Discord")
}
@ -386,18 +386,18 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("dingtalk", "DingTalk")
}
if channels.Slack.Enabled && channels.Slack.BotToken() != "" {
if channels.Slack.Enabled && channels.Slack.BotToken.String() != "" {
m.initChannel("slack", "Slack")
}
if channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken() != "" {
m.config.Channels.Matrix.AccessToken.String() != "" {
m.initChannel("matrix", "Matrix")
}
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken() != "" {
if channels.LINE.Enabled && channels.LINE.ChannelAccessToken.String() != "" {
m.initChannel("line", "LINE")
}
@ -405,15 +405,15 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("onebot", "OneBot")
}
if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret() != "" {
if channels.WeCom.Enabled && channels.WeCom.BotID != "" && channels.WeCom.Secret.String() != "" {
m.initChannel("wecom", "WeCom")
}
if channels.Weixin.Enabled && channels.Weixin.Token() != "" {
if channels.Weixin.Enabled && channels.Weixin.Token.String() != "" {
m.initChannel("weixin", "Weixin")
}
if channels.Pico.Enabled && channels.Pico.Token() != "" {
if channels.Pico.Enabled && channels.Pico.Token.String() != "" {
m.initChannel("pico", "Pico")
}

View file

@ -33,35 +33,35 @@ func toChannelHashes(cfg *config.Config) map[string]string {
func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
switch key {
case "pico":
value["token"] = ch.Pico.Token()
value["token"] = ch.Pico.Token.String()
case "telegram":
value["token"] = ch.Telegram.Token()
value["token"] = ch.Telegram.Token.String()
case "discord":
value["token"] = ch.Discord.Token()
value["token"] = ch.Discord.Token.String()
case "slack":
value["bot_token"] = ch.Slack.BotToken()
value["app_token"] = ch.Slack.AppToken()
value["bot_token"] = ch.Slack.BotToken.String()
value["app_token"] = ch.Slack.AppToken.String()
case "matrix":
value["token"] = ch.Matrix.AccessToken()
value["token"] = ch.Matrix.AccessToken.String()
case "onebot":
value["token"] = ch.OneBot.AccessToken()
value["token"] = ch.OneBot.AccessToken.String()
case "line":
value["token"] = ch.LINE.ChannelAccessToken()
value["secret"] = ch.LINE.ChannelSecret()
value["token"] = ch.LINE.ChannelAccessToken.String()
value["secret"] = ch.LINE.ChannelSecret.String()
case "wecom":
value["secret"] = ch.WeCom.Secret()
value["secret"] = ch.WeCom.Secret.String()
case "dingtalk":
value["secret"] = ch.QQ.AppSecret()
value["secret"] = ch.DingTalk.ClientSecret.String()
case "qq":
value["secret"] = ch.DingTalk.ClientSecret()
value["secret"] = ch.QQ.AppSecret.String()
case "irc":
value["password"] = ch.IRC.Password()
value["serv_password"] = ch.IRC.NickServPassword()
value["sasl_password"] = ch.IRC.SASLPassword()
value["password"] = ch.IRC.Password.String()
value["serv_password"] = ch.IRC.NickServPassword.String()
value["sasl_password"] = ch.IRC.SASLPassword.String()
case "feishu":
value["app_secret"] = ch.Feishu.AppSecret()
value["encrypt_key"] = ch.Feishu.EncryptKey()
value["verification_token"] = ch.Feishu.VerificationToken()
value["app_secret"] = ch.Feishu.AppSecret.String()
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
value["verification_token"] = ch.Feishu.VerificationToken.String()
}
}
@ -125,45 +125,45 @@ func toChannelConfig(cfg *config.Config, list []string) (*config.ChannelsConfig,
func updateKeys(newcfg, old *config.ChannelsConfig) {
if newcfg.Pico.Enabled {
newcfg.Pico.SetToken(old.Pico.Token())
newcfg.Pico.Token = old.Pico.Token
}
if newcfg.Telegram.Enabled {
newcfg.Telegram.SetToken(old.Telegram.Token())
newcfg.Telegram.Token = old.Telegram.Token
}
if newcfg.Discord.Enabled {
newcfg.Discord.SetToken(old.Discord.Token())
newcfg.Discord.Token = old.Discord.Token
}
if newcfg.Slack.Enabled {
newcfg.Slack.SetBotToken(old.Slack.BotToken())
newcfg.Slack.SetAppToken(old.Slack.AppToken())
newcfg.Slack.BotToken = old.Slack.BotToken
newcfg.Slack.AppToken = old.Slack.AppToken
}
if newcfg.Matrix.Enabled {
newcfg.Matrix.SetAccessToken(old.Matrix.AccessToken())
newcfg.Matrix.AccessToken = old.Matrix.AccessToken
}
if newcfg.OneBot.Enabled {
newcfg.OneBot.SetAccessToken(old.OneBot.AccessToken())
newcfg.OneBot.AccessToken = old.OneBot.AccessToken
}
if newcfg.LINE.Enabled {
newcfg.LINE.SetChannelAccessToken(old.LINE.ChannelAccessToken())
newcfg.LINE.SetChannelSecret(old.LINE.ChannelSecret())
newcfg.LINE.ChannelAccessToken = old.LINE.ChannelAccessToken
newcfg.LINE.ChannelSecret = old.LINE.ChannelSecret
}
if newcfg.WeCom.Enabled {
newcfg.WeCom.SetSecret(old.WeCom.Secret())
newcfg.WeCom.Secret = old.WeCom.Secret
}
if newcfg.DingTalk.Enabled {
newcfg.DingTalk.SetClientSecret(old.DingTalk.ClientSecret())
newcfg.DingTalk.ClientSecret = old.DingTalk.ClientSecret
}
if newcfg.QQ.Enabled {
newcfg.QQ.SetAppSecret(old.QQ.AppSecret())
newcfg.QQ.AppSecret = old.QQ.AppSecret
}
if newcfg.IRC.Enabled {
newcfg.IRC.SetPassword(old.IRC.Password())
newcfg.IRC.SetNickServPassword(old.IRC.NickServPassword())
newcfg.IRC.SetSASLPassword(old.IRC.SASLPassword())
newcfg.IRC.Password = old.IRC.Password
newcfg.IRC.NickServPassword = old.IRC.NickServPassword
newcfg.IRC.SASLPassword = old.IRC.SASLPassword
}
if newcfg.Feishu.Enabled {
newcfg.Feishu.SetAppSecret(old.Feishu.AppSecret())
newcfg.Feishu.SetEncryptKey(old.Feishu.EncryptKey())
newcfg.Feishu.SetVerificationToken(old.Feishu.VerificationToken())
newcfg.Feishu.AppSecret = old.Feishu.AppSecret
newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey
newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken
}
}

View file

@ -41,11 +41,11 @@ func TestToChannelHashes(t *testing.T) {
cc, err := toChannelConfig(cfg3, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "114314", cc.Telegram.Token())
assert.Equal(t, "114314", cc.Telegram.Token.String())
assert.Equal(t, true, cc.Telegram.Enabled)
cc, err = toChannelConfig(cfg2, added)
assert.NoError(t, err)
logger.Debugf("cc: %#v", cc.Telegram)
assert.Equal(t, "", cc.Telegram.Token())
assert.Equal(t, "", cc.Telegram.Token.String())
assert.Equal(t, false, cc.Telegram.Enabled)
}

View file

@ -200,7 +200,7 @@ func NewMatrixChannel(
) (*MatrixChannel, error) {
homeserver := strings.TrimSpace(cfg.Homeserver)
userID := strings.TrimSpace(cfg.UserID)
accessToken := strings.TrimSpace(cfg.AccessToken())
accessToken := strings.TrimSpace(cfg.AccessToken.String())
if homeserver == "" {
return nil, fmt.Errorf("matrix homeserver is required")
}

View file

@ -184,8 +184,8 @@ func (c *OneBotChannel) connect() error {
dialer.HandshakeTimeout = 10 * time.Second
header := make(map[string][]string)
if c.config.AccessToken() != "" {
header["Authorization"] = []string{"Bearer " + c.config.AccessToken()}
if c.config.AccessToken.String() != "" {
header["Authorization"] = []string{"Bearer " + c.config.AccessToken.String()}
}
conn, resp, err := dialer.Dial(c.config.WSUrl, header)

View file

@ -81,8 +81,8 @@ func (c *PicoClientChannel) Stop(ctx context.Context) error {
func (c *PicoClientChannel) dial() error {
header := http.Header{}
if c.config.Token != "" {
header.Set("Authorization", "Bearer "+c.config.Token)
if c.config.Token.String() != "" {
header.Set("Authorization", "Bearer "+c.config.Token.String())
}
ws, resp, err := websocket.DefaultDialer.DialContext(c.ctx, c.config.URL, header)

View file

@ -106,7 +106,7 @@ func TestClientChannel_ConnectAndSend(t *testing.T) {
mb := bus.NewMessageBus()
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
Token: "test-token",
Token: *config.NewSecureString("test-token"),
SessionID: "sess-1",
PingInterval: 60,
ReadTimeout: 10,
@ -139,7 +139,7 @@ func TestClientChannel_AuthFailure(t *testing.T) {
ch, err := NewPicoClientChannel(config.PicoClientConfig{
URL: wsURL(srv.URL),
Token: "wrong-token",
Token: *config.NewSecureString("wrong-token"),
}, bus.NewMessageBus())
if err != nil {
t.Fatal(err)

View file

@ -65,7 +65,7 @@ type PicoChannel struct {
// NewPicoChannel creates a new Pico Protocol channel.
func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) {
if cfg.Token() == "" {
if cfg.Token.String() == "" {
return nil, fmt.Errorf("pico token is required")
}
@ -381,7 +381,7 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// 2. Sec-WebSocket-Protocol "token.<value>" (for browsers that can't set headers)
// 3. Query parameter "token" (only when AllowTokenQuery is on)
func (c *PicoChannel) authenticate(r *http.Request) bool {
token := c.config.Token()
token := c.config.Token.String()
if token == "" {
return false
}
@ -412,7 +412,7 @@ func (c *PicoChannel) authenticate(r *http.Request) bool {
// matchedSubprotocol returns the "token.<value>" subprotocol that matches
// the configured token, or "" if none do.
func (c *PicoChannel) matchedSubprotocol(r *http.Request) string {
token := c.config.Token()
token := c.config.Token.String()
for _, proto := range websocket.Subprotocols(r) {
if after, ok := strings.CutPrefix(proto, "token."); ok && after == token {
return proto

View file

@ -98,7 +98,7 @@ func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel,
}
func (c *QQChannel) Start(ctx context.Context) error {
if c.config.AppID == "" || c.config.AppSecret() == "" {
if c.config.AppID == "" || c.config.AppSecret.String() == "" {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
@ -112,7 +112,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
// create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret(),
AppSecret: c.config.AppSecret.String(),
}
c.tokenSource = token.NewQQBotTokenSource(credentials)

View file

@ -37,13 +37,13 @@ type slackMessageRef struct {
}
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
if cfg.BotToken() == "" || cfg.AppToken() == "" {
if cfg.BotToken.String() == "" || cfg.AppToken.String() == "" {
return nil, fmt.Errorf("slack bot_token and app_token are required")
}
api := slack.New(
cfg.BotToken(),
slack.OptionAppLevelToken(cfg.AppToken()),
cfg.BotToken.String(),
slack.OptionAppLevelToken(cfg.AppToken.String()),
)
socketClient := socketmode.New(api)
@ -516,7 +516,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{
LoggerPrefix: "slack",
ExtraHeaders: map[string]string{
"Authorization": "Bearer " + c.config.BotToken(),
"Authorization": "Bearer " + c.config.BotToken.String(),
},
})
}

View file

@ -103,7 +103,7 @@ func TestNewSlackChannel(t *testing.T) {
t.Run("missing bot token", func(t *testing.T) {
cfg := config.SlackConfig{}
cfg.SetAppToken("xapp-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
_, err := NewSlackChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing bot_token, got nil")
@ -112,7 +112,7 @@ func TestNewSlackChannel(t *testing.T) {
t.Run("missing app token", func(t *testing.T) {
cfg := config.SlackConfig{}
cfg.SetBotToken("xoxb-test")
cfg.BotToken = *config.NewSecureString("xoxb-test")
_, err := NewSlackChannel(cfg, msgBus)
if err == nil {
t.Error("expected error for missing app_token, got nil")
@ -123,8 +123,8 @@ func TestNewSlackChannel(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{"U123"},
}
cfg.SetBotToken("xoxb-test")
cfg.SetAppToken("xapp-test")
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, err := NewSlackChannel(cfg, msgBus)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@ -145,8 +145,8 @@ func TestSlackChannelIsAllowed(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{},
}
cfg.SetBotToken("xoxb-test")
cfg.SetAppToken("xapp-test")
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
if !ch.IsAllowed("U_ANYONE") {
t.Error("empty allowlist should allow all users")
@ -157,8 +157,8 @@ func TestSlackChannelIsAllowed(t *testing.T) {
cfg := config.SlackConfig{
AllowFrom: []string{"U_ALLOWED"},
}
cfg.SetBotToken("xoxb-test")
cfg.SetAppToken("xapp-test")
cfg.BotToken = *config.NewSecureString("xoxb-test")
cfg.AppToken = *config.NewSecureString("xapp-test")
ch, _ := NewSlackChannel(cfg, msgBus)
if !ch.IsAllowed("U_ALLOWED") {
t.Error("allowed user should pass allowlist check")

View file

@ -83,7 +83,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token(), opts...)
bot, err := telego.NewBot(telegramCfg.Token.String(), opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}

View file

@ -109,7 +109,7 @@ func (s *recentMessageSet) Mark(id string) bool {
}
func NewChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComChannel, error) {
if cfg.BotID == "" || cfg.Secret() == "" {
if cfg.BotID == "" || cfg.Secret.String() == "" {
return nil, fmt.Errorf("wecom bot_id and secret are required")
}
if cfg.WebSocketURL == "" {
@ -356,7 +356,7 @@ func (c *WeComChannel) runConnection() error {
Headers: wecomHeaders{ReqID: randomID(10)},
Body: map[string]string{
"bot_id": c.config.BotID,
"secret": c.config.Secret(),
"secret": c.config.Secret.String(),
},
}, wecomCommandTimeout); writeErr != nil {
return writeErr

View file

@ -46,7 +46,7 @@ func picoclawHomeDir() string {
func buildWeixinSyncBufPath(cfg config.WeixinConfig) string {
key := "default"
token := strings.TrimSpace(cfg.Token())
token := strings.TrimSpace(cfg.Token.String())
if token != "" {
sum := sha256.Sum256([]byte(strings.TrimSpace(cfg.BaseURL) + "|" + token))
key = hex.EncodeToString(sum[:8])

View file

@ -42,7 +42,7 @@ func init() {
// NewWeixinChannel creates a new WeixinChannel from config.
func NewWeixinChannel(cfg config.WeixinConfig, messageBus *bus.MessageBus) (*WeixinChannel, error) {
api, err := NewApiClient(cfg.BaseURL, cfg.Token(), cfg.Proxy)
api, err := NewApiClient(cfg.BaseURL, cfg.Token.String(), cfg.Proxy)
if err != nil {
return nil, fmt.Errorf("weixin: failed to create API client: %w", err)
}

File diff suppressed because it is too large Load diff

View file

@ -102,53 +102,39 @@ type channelsConfigV0 struct {
IRC ircConfigV0 `json:"irc"`
}
func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
telegram, telegramSecurity := v.Telegram.ToTelegramConfig()
feishu, feishuSecurity := v.Feishu.ToFeishuConfig()
discord, discordSecurity := v.Discord.ToDiscordConfig()
func (v *channelsConfigV0) ToChannelsConfig() ChannelsConfig {
telegram := v.Telegram.ToTelegramConfig()
feishu := v.Feishu.ToFeishuConfig()
discord := v.Discord.ToDiscordConfig()
maixcam := v.MaixCam.ToMaixCamConfig()
qq, qqSecurity := v.QQ.ToQQConfig()
weixin, weixinSecurity := v.Weixin.ToWeiXinConfig()
dingtalk, dingtalkSecurity := v.DingTalk.ToDingTalkConfig()
slack, slackSecurity := v.Slack.ToSlackConfig()
matrix, matrixSecurity := v.Matrix.ToMatrixConfig()
line, lineSecurity := v.LINE.ToLINEConfig()
onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
wecom, wecomSecurity := v.WeCom.ToWeComConfig()
pico, picoSecurity := v.Pico.ToPicoConfig()
irc, ircSecurity := v.IRC.ToIRCConfig()
qq := v.QQ.ToQQConfig()
weixin := v.Weixin.ToWeiXinConfig()
dingtalk := v.DingTalk.ToDingTalkConfig()
slack := v.Slack.ToSlackConfig()
matrix := v.Matrix.ToMatrixConfig()
line := v.LINE.ToLINEConfig()
onebot := v.OneBot.ToOneBotConfig()
wecom := v.WeCom.ToWeComConfig()
pico := v.Pico.ToPicoConfig()
irc := v.IRC.ToIRCConfig()
return ChannelsConfig{
WhatsApp: v.WhatsApp,
Telegram: telegram,
Feishu: feishu,
Discord: discord,
MaixCam: maixcam,
QQ: qq,
Weixin: weixin,
DingTalk: dingtalk,
Slack: slack,
Matrix: matrix,
LINE: line,
OneBot: onebot,
WeCom: wecom,
Pico: pico,
IRC: irc,
}, ChannelsSecurity{
Telegram: telegramSecurity,
Feishu: feishuSecurity,
Discord: discordSecurity,
QQ: qqSecurity,
Weixin: weixinSecurity,
DingTalk: dingtalkSecurity,
Slack: slackSecurity,
Matrix: matrixSecurity,
LINE: lineSecurity,
OneBot: onebotSecurity,
WeCom: wecomSecurity,
Pico: picoSecurity,
IRC: ircSecurity,
}
WhatsApp: v.WhatsApp,
Telegram: telegram,
Feishu: feishu,
Discord: discord,
MaixCam: maixcam,
QQ: qq,
Weixin: weixin,
DingTalk: dingtalk,
Slack: slack,
Matrix: matrix,
LINE: line,
OneBot: onebot,
WeCom: wecom,
Pico: pico,
IRC: irc,
}
}
type qqConfigV0 struct {
@ -163,13 +149,7 @@ type qqConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
var sec *QQSecurity
if v.AppSecret != "" {
sec = &QQSecurity{
AppSecret: v.AppSecret,
}
}
func (v *qqConfigV0) ToQQConfig() QQConfig {
return QQConfig{
Enabled: v.Enabled,
AppID: v.AppID,
@ -179,7 +159,8 @@ func (v *qqConfigV0) ToQQConfig() (QQConfig, *QQSecurity) {
MaxBase64FileSizeMiB: v.MaxBase64FileSizeMiB,
SendMarkdown: v.SendMarkdown,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
AppSecret: *NewSecureString(v.AppSecret),
}
}
type telegramConfigV0 struct {
@ -195,16 +176,9 @@ type telegramConfigV0 struct {
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
}
func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity) {
var sec *TelegramSecurity
if v.Token != "" {
sec = &TelegramSecurity{
Token: v.Token,
}
}
return TelegramConfig{
func (v *telegramConfigV0) ToTelegramConfig() TelegramConfig {
cfg := TelegramConfig{
Enabled: v.Enabled,
token: v.Token,
BaseURL: v.BaseURL,
Proxy: v.Proxy,
AllowFrom: v.AllowFrom,
@ -213,7 +187,11 @@ func (v *telegramConfigV0) ToTelegramConfig() (TelegramConfig, *TelegramSecurity
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
UseMarkdownV2: v.UseMarkdownV2,
}, sec
}
if v.Token != "" {
cfg.Token = *NewSecureString(v.Token)
}
return cfg
}
type feishuConfigV0 struct {
@ -230,24 +208,25 @@ type feishuConfigV0 struct {
IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"`
}
func (v *feishuConfigV0) ToFeishuConfig() (FeishuConfig, *FeishuSecurity) {
var sec *FeishuSecurity
if v.AppSecret != "" || v.EncryptKey != "" || v.VerificationToken != "" {
sec = &FeishuSecurity{
AppSecret: v.AppSecret,
EncryptKey: v.EncryptKey,
VerificationToken: v.VerificationToken,
}
}
return FeishuConfig{
func (v *feishuConfigV0) ToFeishuConfig() FeishuConfig {
cfg := FeishuConfig{
Enabled: v.Enabled,
AppID: v.AppID,
appSecret: v.AppSecret,
AllowFrom: v.AllowFrom,
GroupTrigger: v.GroupTrigger,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.AppSecret != "" {
cfg.AppSecret = *NewSecureString(v.AppSecret)
}
if v.EncryptKey != "" {
cfg.EncryptKey = *NewSecureString(v.EncryptKey)
}
if v.VerificationToken != "" {
cfg.VerificationToken = *NewSecureString(v.VerificationToken)
}
return cfg
}
type discordConfigV0 struct {
@ -262,16 +241,9 @@ type discordConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
var sec *DiscordSecurity
if v.Token != "" {
sec = &DiscordSecurity{
Token: v.Token,
}
}
return DiscordConfig{
func (v *discordConfigV0) ToDiscordConfig() DiscordConfig {
cfg := DiscordConfig{
Enabled: v.Enabled,
token: v.Token,
Proxy: v.Proxy,
AllowFrom: v.AllowFrom,
MentionOnly: v.MentionOnly,
@ -279,7 +251,11 @@ func (v *discordConfigV0) ToDiscordConfig() (DiscordConfig, *DiscordSecurity) {
Typing: v.Typing,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.Token != "" {
cfg.Token = *NewSecureString(v.Token)
}
return cfg
}
type maixcamConfigV0 struct {
@ -309,21 +285,18 @@ type dingtalkConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
}
func (v *dingtalkConfigV0) ToDingTalkConfig() (DingTalkConfig, *DingTalkSecurity) {
var sec *DingTalkSecurity
if v.ClientSecret != "" {
sec = &DingTalkSecurity{
ClientSecret: v.ClientSecret,
}
}
return DingTalkConfig{
func (v *dingtalkConfigV0) ToDingTalkConfig() DingTalkConfig {
cfg := DingTalkConfig{
Enabled: v.Enabled,
ClientID: v.ClientID,
clientSecret: v.ClientSecret,
AllowFrom: v.AllowFrom,
GroupTrigger: v.GroupTrigger,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.ClientSecret != "" {
cfg.ClientSecret = *NewSecureString(v.ClientSecret)
}
return cfg
}
type slackConfigV0 struct {
@ -337,24 +310,22 @@ type slackConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
func (v *slackConfigV0) ToSlackConfig() (SlackConfig, *SlackSecurity) {
var sec *SlackSecurity
if v.BotToken != "" || v.AppToken != "" {
sec = &SlackSecurity{
BotToken: v.BotToken,
AppToken: v.AppToken,
}
}
return SlackConfig{
func (v *slackConfigV0) ToSlackConfig() SlackConfig {
cfg := SlackConfig{
Enabled: v.Enabled,
botToken: v.BotToken,
appToken: v.AppToken,
AllowFrom: v.AllowFrom,
GroupTrigger: v.GroupTrigger,
Typing: v.Typing,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.BotToken != "" {
cfg.BotToken = *NewSecureString(v.BotToken)
}
if v.AppToken != "" {
cfg.AppToken = *NewSecureString(v.AppToken)
}
return cfg
}
type matrixConfigV0 struct {
@ -371,18 +342,11 @@ type matrixConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
var sec *MatrixSecurity
if v.AccessToken != "" {
sec = &MatrixSecurity{
AccessToken: v.AccessToken,
}
}
return MatrixConfig{
func (v *matrixConfigV0) ToMatrixConfig() MatrixConfig {
cfg := MatrixConfig{
Enabled: v.Enabled,
Homeserver: v.Homeserver,
UserID: v.UserID,
accessToken: v.AccessToken,
DeviceID: v.DeviceID,
JoinOnInvite: v.JoinOnInvite,
MessageFormat: v.MessageFormat,
@ -390,7 +354,11 @@ func (v *matrixConfigV0) ToMatrixConfig() (MatrixConfig, *MatrixSecurity) {
GroupTrigger: v.GroupTrigger,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.AccessToken != "" {
cfg.AccessToken = *NewSecureString(v.AccessToken)
}
return cfg
}
type lineConfigV0 struct {
@ -407,18 +375,9 @@ type lineConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
}
func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
var sec *LINESecurity
if v.ChannelSecret != "" || v.ChannelAccessToken != "" {
sec = &LINESecurity{
ChannelSecret: v.ChannelSecret,
ChannelAccessToken: v.ChannelAccessToken,
}
}
return LINEConfig{
func (v *lineConfigV0) ToLINEConfig() LINEConfig {
cfg := LINEConfig{
Enabled: v.Enabled,
channelSecret: v.ChannelSecret,
channelAccessToken: v.ChannelAccessToken,
WebhookHost: v.WebhookHost,
WebhookPort: v.WebhookPort,
WebhookPath: v.WebhookPath,
@ -427,7 +386,14 @@ func (v *lineConfigV0) ToLINEConfig() (LINEConfig, *LINESecurity) {
Typing: v.Typing,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.ChannelSecret != "" {
cfg.ChannelSecret = *NewSecureString(v.ChannelSecret)
}
if v.ChannelAccessToken != "" {
cfg.ChannelAccessToken = *NewSecureString(v.ChannelAccessToken)
}
return cfg
}
type onebotConfigV0 struct {
@ -443,17 +409,10 @@ type onebotConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
}
func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
var sec *OneBotSecurity
if v.AccessToken != "" {
sec = &OneBotSecurity{
AccessToken: v.AccessToken,
}
}
return OneBotConfig{
func (v *onebotConfigV0) ToOneBotConfig() OneBotConfig {
cfg := OneBotConfig{
Enabled: v.Enabled,
WSUrl: v.WSUrl,
accessToken: v.AccessToken,
ReconnectInterval: v.ReconnectInterval,
GroupTriggerPrefix: v.GroupTriggerPrefix,
AllowFrom: v.AllowFrom,
@ -461,7 +420,11 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
Typing: v.Typing,
Placeholder: v.Placeholder,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.AccessToken != "" {
cfg.AccessToken = *NewSecureString(v.AccessToken)
}
return cfg
}
type wecomConfigV0 struct {
@ -478,20 +441,19 @@ type wecomConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
}
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) {
var sec *WeComSecurity
if v.Secret != "" {
sec = &WeComSecurity{Secret: v.Secret}
}
return WeComConfig{
func (v *wecomConfigV0) ToWeComConfig() WeComConfig {
cfg := WeComConfig{
Enabled: v.Enabled,
BotID: v.BotID,
secret: v.Secret,
WebSocketURL: v.WebSocketURL,
SendThinkingMessage: v.SendThinkingMessage,
AllowFrom: v.AllowFrom,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.Secret != "" {
cfg.Secret = *NewSecureString(v.Secret)
}
return cfg
}
type weixinConfigV0 struct {
@ -504,22 +466,19 @@ type weixinConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"`
}
func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
var sec *WeixinSecurity
if v.Token != "" {
sec = &WeixinSecurity{
Token: v.Token,
}
}
return WeixinConfig{
func (v *weixinConfigV0) ToWeiXinConfig() WeixinConfig {
cfg := WeixinConfig{
Enabled: v.Enabled,
token: v.Token,
BaseURL: v.BaseURL,
CDNBaseURL: v.CDNBaseURL,
Proxy: v.Proxy,
AllowFrom: v.AllowFrom,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.Token != "" {
cfg.Token = *NewSecureString(v.Token)
}
return cfg
}
type picoConfigV0 struct {
@ -535,16 +494,9 @@ type picoConfigV0 struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
var sec *PicoSecurity
if v.Token != "" {
sec = &PicoSecurity{
Token: v.Token,
}
}
return PicoConfig{
func (v *picoConfigV0) ToPicoConfig() PicoConfig {
cfg := PicoConfig{
Enabled: v.Enabled,
token: v.Token,
AllowTokenQuery: v.AllowTokenQuery,
AllowOrigins: v.AllowOrigins,
PingInterval: v.PingInterval,
@ -553,7 +505,11 @@ func (v *picoConfigV0) ToPicoConfig() (PicoConfig, *PicoSecurity) {
MaxConnections: v.MaxConnections,
AllowFrom: v.AllowFrom,
Placeholder: v.Placeholder,
}, sec
}
if v.Token != "" {
cfg.Token = *NewSecureString(v.Token)
}
return cfg
}
type ircConfigV0 struct {
@ -575,33 +531,32 @@ type ircConfigV0 struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"`
}
func (v *ircConfigV0) ToIRCConfig() (IRCConfig, *IRCSecurity) {
var sec *IRCSecurity
if v.Password != "" || v.NickServPassword != "" || v.SASLPassword != "" {
sec = &IRCSecurity{
Password: v.Password,
NickServPassword: v.NickServPassword,
SASLPassword: v.SASLPassword,
}
}
return IRCConfig{
func (v *ircConfigV0) ToIRCConfig() IRCConfig {
cfg := IRCConfig{
Enabled: v.Enabled,
Server: v.Server,
TLS: v.TLS,
Nick: v.Nick,
User: v.User,
RealName: v.RealName,
password: v.Password,
nickServPassword: v.NickServPassword,
SASLUser: v.SASLUser,
saslPassword: v.SASLPassword,
Channels: v.Channels,
RequestCaps: v.RequestCaps,
AllowFrom: v.AllowFrom,
GroupTrigger: v.GroupTrigger,
Typing: v.Typing,
ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
if v.Password != "" {
cfg.Password = *NewSecureString(v.Password)
}
if v.NickServPassword != "" {
cfg.NickServPassword = *NewSecureString(v.NickServPassword)
}
if v.SASLPassword != "" {
cfg.SASLPassword = *NewSecureString(v.SASLPassword)
}
return cfg
}
type providersConfigV0 struct {
@ -748,15 +703,12 @@ func (c *configV0) Migrate() (*Config, error) {
// Copy other top-level fields
cfg.Bindings = c.Bindings
cfg.Session = c.Session
var secChannels ChannelsSecurity
cfg.Channels, secChannels = c.Channels.ToChannelsConfig()
cfg.Channels = c.Channels.ToChannelsConfig()
cfg.Gateway = c.Gateway
var secWeb WebToolsSecurity
cfg.Tools.Web, secWeb = c.Tools.Web.ToWebToolsConfig()
cfg.Tools.Web = c.Tools.Web.ToWebToolsConfig()
cfg.Tools.Cron = c.Tools.Cron
cfg.Tools.Exec = c.Tools.Exec
var secSkills *SkillsSecurity
cfg.Tools.Skills, secSkills = c.Tools.Skills.ToSkillsToolsConfig()
cfg.Tools.Skills = c.Tools.Skills.ToSkillsToolsConfig()
cfg.Tools.MediaCleanup = c.Tools.MediaCleanup
cfg.Tools.MCP = c.Tools.MCP
cfg.Tools.AppendFile = c.Tools.AppendFile
@ -778,15 +730,10 @@ func (c *configV0) Migrate() (*Config, error) {
cfg.Heartbeat = c.Heartbeat
cfg.Devices = c.Devices
secModels := make(map[string]ModelSecurityEntry, 0)
// Only override ModelList if user provided values
if len(c.ModelList) > 0 {
// Convert []modelConfigV0 to []ModelConfig
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
for i, m := range c.ModelList {
// Merge APIKey and APIKeys, deduplicating
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
cfg.ModelList[i] = &ModelConfig{
ModelName: m.ModelName,
Model: m.Model,
@ -800,27 +747,11 @@ func (c *configV0) Migrate() (*Config, error) {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
apiKeys: mergedKeys,
}
}
names := toNameIndex(cfg.ModelList)
for i, m := range c.ModelList {
// Merge APIKey and APIKeys, deduplicating
mergedKeys := MergeAPIKeys(m.APIKey, m.APIKeys)
if len(mergedKeys) > 0 {
secModels[names[i]] = ModelSecurityEntry{
APIKeys: mergedKeys,
}
APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)),
}
}
}
cfg.WithSecurity(&SecurityConfig{
ModelList: secModels,
Channels: &secChannels,
Web: &secWeb,
Skills: secSkills,
})
cfg.Version = CurrentVersion
return cfg, nil
}
@ -848,17 +779,20 @@ type braveConfigV0 struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
func (v *braveConfigV0) ToBraveConfig() (BraveConfig, *BraveSecurity) {
var sec *BraveSecurity
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
sec = &BraveSecurity{
APIKeys: MergeAPIKeys(v.APIKey, v.APIKeys),
}
func toSecureStrings(keys []string) SecureStrings {
apikeys := make(SecureStrings, len(keys))
for i, key := range keys {
apikeys[i] = NewSecureString(key)
}
return apikeys
}
func (v *braveConfigV0) ToBraveConfig() BraveConfig {
return BraveConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
}, sec
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
type tavilyConfigV0 struct {
@ -869,18 +803,13 @@ type tavilyConfigV0 struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
func (v *tavilyConfigV0) ToTavilyConfig() (TavilyConfig, *TavilySecurity) {
var sec *TavilySecurity
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
sec = &TavilySecurity{
APIKeys: k,
}
}
func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig {
return TavilyConfig{
Enabled: v.Enabled,
BaseURL: v.BaseURL,
MaxResults: v.MaxResults,
}, sec
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
type perplexityConfigV0 struct {
@ -890,17 +819,12 @@ type perplexityConfigV0 struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
func (v *perplexityConfigV0) ToPerplexityConfig() (PerplexityConfig, *PerplexitySecurity) {
var sec *PerplexitySecurity
if k := MergeAPIKeys(v.APIKey, v.APIKeys); len(k) > 0 {
sec = &PerplexitySecurity{
APIKeys: k,
}
}
func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig {
return PerplexityConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
}, sec
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
type glmSearchConfigV0 struct {
@ -910,19 +834,13 @@ type glmSearchConfigV0 struct {
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
}
func (v *glmSearchConfigV0) ToGLMSearchConfig() (GLMSearchConfig, *GLMSearchSecurity) {
var sec *GLMSearchSecurity
if v.APIKey != "" {
sec = &GLMSearchSecurity{
APIKey: v.APIKey,
}
}
func (v *glmSearchConfigV0) ToGLMSearchConfig() GLMSearchConfig {
return GLMSearchConfig{
Enabled: v.Enabled,
apiKey: v.APIKey,
APIKey: *NewSecureString(v.APIKey),
BaseURL: v.BaseURL,
SearchEngine: v.SearchEngine,
}, sec
}
}
type baiduSearchConfigV0 struct {
@ -932,49 +850,37 @@ type baiduSearchConfigV0 struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"`
}
func (v *baiduSearchConfigV0) ToBaiduSearchConfig() (BaiduSearchConfig, *BaiduSearchSecurity) {
var sec *BaiduSearchSecurity
if v.APIKey != "" {
sec = &BaiduSearchSecurity{
APIKey: v.APIKey,
}
}
func (v *baiduSearchConfigV0) ToBaiduSearchConfig() BaiduSearchConfig {
return BaiduSearchConfig{
Enabled: v.Enabled,
apiKey: v.APIKey,
APIKey: *NewSecureString(v.APIKey),
BaseURL: v.BaseURL,
MaxResults: v.MaxResults,
}, sec
}
}
func (v *webToolsConfigV0) ToWebToolsConfig() (WebToolsConfig, WebToolsSecurity) {
brave, braveSecurity := v.Brave.ToBraveConfig()
tavily, tavilySecurity := v.Tavily.ToTavilyConfig()
perplexity, perplexitySecurity := v.Perplexity.ToPerplexityConfig()
glmSearch, glmSearchSecurity := v.GLMSearch.ToGLMSearchConfig()
baiduSearch, baiduSearchSecurity := v.BaiduSearch.ToBaiduSearchConfig()
func (v *webToolsConfigV0) ToWebToolsConfig() WebToolsConfig {
brave := v.Brave.ToBraveConfig()
tavily := v.Tavily.ToTavilyConfig()
perplexity := v.Perplexity.ToPerplexityConfig()
glmSearch := v.GLMSearch.ToGLMSearchConfig()
baiduSearch := v.BaiduSearch.ToBaiduSearchConfig()
return WebToolsConfig{
ToolConfig: v.ToolConfig,
Brave: brave,
Tavily: tavily,
DuckDuckGo: v.DuckDuckGo,
Perplexity: perplexity,
SearXNG: v.SearXNG,
GLMSearch: glmSearch,
BaiduSearch: baiduSearch,
PreferNative: v.PreferNative,
Proxy: v.Proxy,
FetchLimitBytes: v.FetchLimitBytes,
Format: v.Format,
PrivateHostWhitelist: v.PrivateHostWhitelist,
}, WebToolsSecurity{
Brave: braveSecurity,
Tavily: tavilySecurity,
Perplexity: perplexitySecurity,
GLMSearch: glmSearchSecurity,
BaiduSearch: baiduSearchSecurity,
}
ToolConfig: v.ToolConfig,
Brave: brave,
Tavily: tavily,
DuckDuckGo: v.DuckDuckGo,
Perplexity: perplexity,
SearXNG: v.SearXNG,
GLMSearch: glmSearch,
PreferNative: v.PreferNative,
Proxy: v.Proxy,
FetchLimitBytes: v.FetchLimitBytes,
Format: v.Format,
PrivateHostWhitelist: v.PrivateHostWhitelist,
BaiduSearch: baiduSearch,
}
}
type skillsToolsConfigV0 struct {
@ -997,20 +903,17 @@ type clawHubRegistryConfigV0 struct {
SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"`
}
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() (ClawHubRegistryConfig, *ClawHubSecurity) {
var sec *ClawHubSecurity
if v.AuthToken != "" {
sec = &ClawHubSecurity{
AuthToken: v.AuthToken,
}
}
return ClawHubRegistryConfig{
func (v *clawHubRegistryConfigV0) ToClawHubRegistryConfig() ClawHubRegistryConfig {
cfg := ClawHubRegistryConfig{
Enabled: v.Enabled,
BaseURL: v.BaseURL,
authToken: v.AuthToken,
SearchPath: v.SearchPath,
SkillsPath: v.SkillsPath,
}, sec
}
if v.AuthToken != "" {
cfg.AuthToken = *NewSecureString(v.AuthToken)
}
return cfg
}
type skillsGithubConfigV0 struct {
@ -1018,43 +921,29 @@ type skillsGithubConfigV0 struct {
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
}
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() (SkillsGithubConfig, *GithubSecurity) {
var sec *GithubSecurity
if v.Token != "" {
sec = &GithubSecurity{
Token: v.Token,
}
}
func (v *skillsGithubConfigV0) ToSkillsGithubConfig() SkillsGithubConfig {
return SkillsGithubConfig{
token: v.Token,
Token: *NewSecureString(v.Token),
Proxy: v.Proxy,
}, sec
}
}
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() (SkillsRegistriesConfig, *ClawHubSecurity) {
clawHub, clawHubSecurity := v.ClawHub.ToClawHubRegistryConfig()
func (v *skillsRegistriesConfigV0) ToSkillsRegistriesConfig() SkillsRegistriesConfig {
clawHub := v.ClawHub.ToClawHubRegistryConfig()
return SkillsRegistriesConfig{
ClawHub: clawHub,
}, clawHubSecurity
}
}
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() (SkillsToolsConfig, *SkillsSecurity) {
registries, registriesSecurity := v.Registries.ToSkillsRegistriesConfig()
github, githubSecurity := v.Github.ToSkillsGithubConfig()
var sec *SkillsSecurity
if githubSecurity != nil || registriesSecurity != nil {
sec = &SkillsSecurity{
Github: githubSecurity,
ClawHub: registriesSecurity,
}
}
func (v *skillsToolsConfigV0) ToSkillsToolsConfig() SkillsToolsConfig {
registries := v.Registries.ToSkillsRegistriesConfig()
github := v.Github.ToSkillsGithubConfig()
return SkillsToolsConfig{
ToolConfig: v.ToolConfig,
Registries: registries,
Github: github,
MaxConcurrentSearches: v.MaxConcurrentSearches,
SearchCache: v.SearchCache,
}, sec
}
}

View file

@ -309,7 +309,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
if cfg.Tools.Web.Brave.MaxResults != 5 {
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
}
if len(cfg.Tools.Web.Brave.APIKeys()) != 0 {
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
t.Error("Brave API key should be empty by default")
}
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
@ -403,12 +403,12 @@ func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
primaryModel := &ModelConfig{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key1"},
APIKeys: SimpleSecureStrings("key1"),
}
virtualModel := &ModelConfig{
ModelName: "gpt-4__key_1",
Model: "openai/gpt-4o",
apiKeys: []string{"key2"},
APIKeys: SimpleSecureStrings("key2"),
isVirtual: true,
}
cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
@ -612,7 +612,7 @@ func TestDefaultConfig_HooksDefaults(t *testing.T) {
func TestDefaultConfig_LogLevel(t *testing.T) {
cfg := DefaultConfig()
if cfg.Gateway.LogLevel != "fatal" {
if cfg.Gateway.LogLevel != "warn" {
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
}
}
@ -1064,11 +1064,10 @@ func TestSaveConfig_EncryptsPlaintextAPIKey(t *testing.T) {
cfg := DefaultConfig()
cfg.ModelList = []*ModelConfig{
{ModelName: "test", Model: "openai/gpt-4", apiKeys: []string{"sk-plaintext"}},
}
cfg.security = &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"sk-plaintext"}}},
{ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("")},
}
cfg.ModelList[0].APIKeys[0].Set("sk-plaintext")
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
@ -1132,8 +1131,9 @@ func TestLoadConfig_FileRefNotSealed(t *testing.T) {
secPath := filepath.Join(dir, SecurityConfigFile)
if err := saveSecurityConfig(
secPath,
&SecurityConfig{ModelList: map[string]ModelSecurityEntry{"test:0": {APIKeys: []string{"file://openai.key"}}}},
); err != nil {
&Config{ModelList: SecureModelList{
&ModelConfig{ModelName: "test", APIKeys: SimpleSecureStrings("file://openai.key")},
}}); err != nil {
t.Fatalf("saveSecurityConfig: %v", err)
}
@ -1165,12 +1165,7 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
// Pre-encrypt one key so we have a genuine enc:// value to put in the config.
if err := SaveConfig(cfgPath, &Config{
ModelList: []*ModelConfig{
{ModelName: "pre", Model: "openai/gpt-4"},
},
security: &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"pre:0": {APIKeys: []string{"sk-already-plain"}},
},
{ModelName: "pre", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-already-plain")},
},
}); err != nil {
t.Fatalf("setup SaveConfig: %v", err)
@ -1199,23 +1194,18 @@ func TestSaveConfig_MixedKeys(t *testing.T) {
t.Fatalf("setup: %v", err)
}
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{ModelName: "plain", Model: "openai/gpt-4", apiKeys: []string{"sk-new-plaintext"}},
{ModelName: "enc", Model: "openai/gpt-4", apiKeys: []string{alreadyEncrypted}},
{ModelName: "file", Model: "openai/gpt-4", apiKeys: []string{"file://api.key"}},
},
security: &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"plain:0": {APIKeys: []string{"sk-new-plaintext"}},
"enc:0": {APIKeys: []string{alreadyEncrypted}},
"file:0": {APIKeys: []string{"file://api.key"}},
},
{ModelName: "plain", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-new-plaintext")},
{ModelName: "enc", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings(alreadyEncrypted)},
{ModelName: "file", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("file://api.key")},
},
}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
t.Logf("alreadyEncrypted: %s", alreadyEncrypted)
raw, _ = os.ReadFile(filepath.Join(dir, SecurityConfigFile))
s := string(raw)
@ -1265,20 +1255,16 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "test-passphrase")
mustSetupSSHKey(t)
if err := SaveConfig(cfgPath, &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{ModelName: "m", Model: "openai/gpt-4", apiKeys: []string{"sk-secret"}},
},
security: &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"m:0": {APIKeys: []string{"sk-secret"}},
},
{ModelName: "m", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-secret")},
},
}); err != nil {
t.Fatalf("setup SaveConfig: %v", err)
}
raw, err := LoadConfig(cfgPath)
assert.NoError(t, err)
encValue := raw.security.ModelList["m:0"].APIKeys[0]
encValue := raw.ModelList[0].APIKeys[0].raw
assert.NotEmpty(t, encValue)
assert.Equal(t, "enc://", encValue[:6])
@ -1311,8 +1297,9 @@ func TestLoadConfig_MixedKeys_NoPassphrase(t *testing.T) {
// Now clear the passphrase — LoadConfig must fail because enc:// cannot be decrypted.
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
_, err = LoadConfig(cfgPath)
cfg2, err := LoadConfig(cfgPath)
if err == nil {
t.Logf("LoadConfig: %#v", cfg2.ModelList)
t.Fatal("LoadConfig should fail when enc:// key is present and no passphrase is set")
}
if !strings.Contains(err.Error(), "passphrase required") {
@ -1340,9 +1327,8 @@ func TestSaveConfig_UsesPassphraseProvider(t *testing.T) {
cfg := DefaultConfig()
cfg.ModelList = []*ModelConfig{
{ModelName: "test", Model: "openai/gpt-4"},
{ModelName: "test", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-plaintext")},
}
cfg.security.ModelList["test:0"] = ModelSecurityEntry{APIKeys: []string{"sk-plaintext"}}
if err := SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
@ -1386,6 +1372,8 @@ func TestLoadConfig_UsesPassphraseProvider(t *testing.T) {
credential.PassphraseProvider = func() string { return testPassphrase }
t.Cleanup(func() { credential.PassphraseProvider = orig })
t.Logf("cfgPath: %s", cfgPath)
cfg, err := LoadConfig(cfgPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
@ -1425,7 +1413,7 @@ func TestConfigLogLevelEmpty(t *testing.T) {
t.Fatalf("LoadConfig: %v", err)
}
// When config omits log_level, the DefaultConfig value ("fatal") is preserved.
if cfg.Gateway.LogLevel != "fatal" {
if cfg.Gateway.LogLevel != "warn" {
t.Errorf("LogLevel = %q, want \"fatal\"", cfg.Gateway.LogLevel)
}
}
@ -1435,17 +1423,15 @@ func TestModelConfig_ExtraBodyRoundTrip(t *testing.T) {
cfgPath := filepath.Join(dir, "config.json")
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{
ModelName: "test-model",
Model: "openai/test",
apiKeys: []string{"sk-test"},
APIKeys: SimpleSecureStrings("sk-test"),
ExtraBody: map[string]any{"custom_field": "value", "num_field": 42},
},
},
security: &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{"test-model:0": {APIKeys: []string{"sk-test"}}},
},
}
if err := SaveConfig(cfgPath, cfg); err != nil {
@ -1497,20 +1483,25 @@ func TestFilterSensitiveData(t *testing.T) {
}
// Test with empty content
cfg.security = &SecurityConfig{}
if got := cfg.FilterSensitiveData(""); got != "" {
t.Errorf("empty content: got %q, want empty", got)
}
// Test short content (less than FilterMinLength=8, should skip filtering)
cfg.security.ModelList = map[string]ModelSecurityEntry{
"test": {APIKeys: []string{"sk-long-key-12345"}},
cfg.ModelList = SecureModelList{
&ModelConfig{
ModelName: "test",
APIKeys: SimpleSecureStrings("sk-long-key-12345"),
},
}
m, err := cfg.GetModelConfig("test")
assert.NoError(t, err)
m.APIKeys = SimpleSecureStrings("sk-long-key-12345")
cfg.Tools.FilterSensitiveData = true
cfg.Tools.FilterMinLength = 8
// Debug: check if sensitive values are collected
values := cfg.security.collectSensitiveValues()
values := cfg.collectSensitiveValues()
t.Logf("collected %d sensitive values: %v", len(values), values)
if got := cfg.FilterSensitiveData("sk-key"); got != "sk-key" {
@ -1538,11 +1529,17 @@ func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
FilterSensitiveData: true,
FilterMinLength: 8,
},
}
cfg.security = &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"model1": {APIKeys: []string{"key-one", "key-two"}},
"model2": {APIKeys: []string{"key-three"}},
ModelList: SecureModelList{
&ModelConfig{
ModelName: "model1",
Model: "openai/model1",
APIKeys: SecureStrings{NewSecureString("key-one"), NewSecureString("key-two")},
},
&ModelConfig{
ModelName: "model2",
Model: "openai/model2",
APIKeys: SecureStrings{NewSecureString("key-three")},
},
},
}
@ -1555,45 +1552,54 @@ func TestFilterSensitiveData_MultipleKeys(t *testing.T) {
func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
cfg := &Config{
// Model API keys
ModelList: SecureModelList{
&ModelConfig{
ModelName: "test-model",
APIKeys: SecureStrings{NewSecureString("sk-model-key-12345")},
},
},
// Channel tokens
Channels: ChannelsConfig{
Telegram: TelegramConfig{Token: *NewSecureString("telegram-bot-token-abcdef")},
Discord: DiscordConfig{Token: *NewSecureString("discord-bot-token-xyz789")},
Slack: SlackConfig{
BotToken: *NewSecureString("xoxb-slack-bot-token"),
AppToken: *NewSecureString("xapp-slack-app-token"),
},
Matrix: MatrixConfig{AccessToken: *NewSecureString("matrix-access-token-abc")},
Feishu: FeishuConfig{
AppSecret: *NewSecureString("feishu-app-secret-123"),
EncryptKey: *NewSecureString("feishu-encrypt-key"),
},
DingTalk: DingTalkConfig{ClientSecret: *NewSecureString("dingtalk-client-secret")},
OneBot: OneBotConfig{AccessToken: *NewSecureString("onebot-access-token")},
WeCom: WeComConfig{Secret: *NewSecureString("wecom-secret")},
Pico: PicoConfig{Token: *NewSecureString("pico-token-abc123")},
IRC: IRCConfig{
Password: *NewSecureString("irc-password"),
NickServPassword: *NewSecureString("nickserv-pass"),
SASLPassword: *NewSecureString("sasl-pass"),
},
},
Tools: ToolsConfig{
FilterSensitiveData: true,
FilterMinLength: 8,
},
}
cfg.security = &SecurityConfig{
// Model API keys
ModelList: map[string]ModelSecurityEntry{
"test-model": {APIKeys: []string{"sk-model-key-12345"}},
},
// Channel tokens
Channels: &ChannelsSecurity{
Telegram: &TelegramSecurity{Token: "telegram-bot-token-abcdef"},
Discord: &DiscordSecurity{Token: "discord-bot-token-xyz789"},
Slack: &SlackSecurity{BotToken: "xoxb-slack-bot-token", AppToken: "xapp-slack-app-token"},
Matrix: &MatrixSecurity{AccessToken: "matrix-access-token-abc"},
Feishu: &FeishuSecurity{AppSecret: "feishu-app-secret-123", EncryptKey: "feishu-encrypt-key"},
DingTalk: &DingTalkSecurity{ClientSecret: "dingtalk-client-secret"},
OneBot: &OneBotSecurity{AccessToken: "onebot-access-token"},
WeCom: &WeComSecurity{Secret: "wecom-secret"},
Pico: &PicoSecurity{Token: "pico-token-abc123"},
IRC: &IRCSecurity{
Password: "irc-password",
NickServPassword: "nickserv-pass",
SASLPassword: "sasl-pass",
// Web tool API keys
Web: WebToolsConfig{
Brave: BraveConfig{APIKeys: SecureStrings{NewSecureString("brave-api-key")}},
Tavily: TavilyConfig{APIKeys: SecureStrings{NewSecureString("tavily-api-key")}},
Perplexity: PerplexityConfig{APIKeys: SecureStrings{NewSecureString("perplexity-api-key")}},
GLMSearch: GLMSearchConfig{APIKey: *NewSecureString("glm-search-key")},
BaiduSearch: BaiduSearchConfig{APIKey: *NewSecureString("baidu-search-key")},
},
// Skills tokens
Skills: SkillsToolsConfig{
Github: SkillsGithubConfig{Token: *NewSecureString("github-token-xyz")},
Registries: SkillsRegistriesConfig{
ClawHub: ClawHubRegistryConfig{AuthToken: *NewSecureString("clawhub-auth-token")},
},
},
},
// Web tool API keys
Web: &WebToolsSecurity{
Brave: &BraveSecurity{APIKeys: []string{"brave-api-key"}},
Tavily: &TavilySecurity{APIKeys: []string{"tavily-api-key"}},
Perplexity: &PerplexitySecurity{APIKeys: []string{"perplexity-api-key"}},
GLMSearch: &GLMSearchSecurity{APIKey: "glm-search-key"},
BaiduSearch: &BaiduSearchSecurity{APIKey: "baidu-search-key"},
},
// Skills tokens
Skills: &SkillsSecurity{
Github: &GithubSecurity{Token: "github-token-xyz"},
ClawHub: &ClawHubSecurity{AuthToken: "clawhub-auth-token"},
},
}

View file

@ -357,7 +357,7 @@ func DefaultConfig() *Config {
Host: "127.0.0.1",
Port: 18790,
HotReload: false,
LogLevel: "fatal",
LogLevel: "warn",
},
Tools: ToolsConfig{
FilterSensitiveData: true,
@ -519,11 +519,5 @@ func DefaultConfig() *Config {
BuildTime: BuildTime,
GoVersion: GoVersion,
},
security: &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{},
Channels: &ChannelsSecurity{},
Web: &WebToolsSecurity{},
Skills: &SkillsSecurity{},
},
}
}

View file

@ -103,8 +103,8 @@ func TestMigration_Integration_LegacyConfigWithoutWorkspace(t *testing.T) {
if !cfg.Channels.Telegram.Enabled {
t.Error("Telegram.Enabled should be true")
}
if cfg.Channels.Telegram.Token() != "test-token" {
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token(), "test-token")
if cfg.Channels.Telegram.Token.String() != "test-token" {
t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token.String(), "test-token")
}
if cfg.Gateway.Port != 18790 {
t.Errorf("Gateway.Port = %d, want %d", cfg.Gateway.Port, 18790)
@ -643,15 +643,15 @@ web:
// Verify that the migrated config has the existing security values
// Telegram token should be preserved
if cfg.Channels.Telegram.Token() != "existing-telegram-token-from-env" {
if cfg.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" {
t.Errorf("Telegram token was overwritten: got %q, want %q",
cfg.Channels.Telegram.Token(), "existing-telegram-token-from-env")
cfg.Channels.Telegram.Token.String(), "existing-telegram-token-from-env")
}
// Discord token should be preserved (even though legacy config didn't have it)
if cfg.Channels.Discord.Token() != "existing-discord-token-from-env" {
if cfg.Channels.Discord.Token.String() != "existing-discord-token-from-env" {
t.Errorf("Discord token was overwritten: got %q, want %q",
cfg.Channels.Discord.Token(), "existing-discord-token-from-env")
cfg.Channels.Discord.Token.String(), "existing-discord-token-from-env")
}
// Model API key should be preserved
@ -667,17 +667,17 @@ web:
}
// Reload the security config from disk to verify it wasn't corrupted
reloadedSec, err := loadSecurityConfig(securityPath)
reloadedSec := cfg
err = loadSecurityConfig(cfg, securityPath)
if err != nil {
t.Fatalf("Failed to reload security config: %v", err)
}
if reloadedSec.Channels.Telegram == nil ||
reloadedSec.Channels.Telegram.Token != "existing-telegram-token-from-env" {
if reloadedSec.Channels.Telegram.Token.String() != "existing-telegram-token-from-env" {
t.Error("Telegram token not preserved in .security.yml file")
}
if reloadedSec.Channels.Discord == nil || reloadedSec.Channels.Discord.Token != "existing-discord-token-from-env" {
if reloadedSec.Channels.Discord.Token.String() != "existing-discord-token-from-env" {
t.Error("Discord token not preserved in .security.yml file")
}
}

View file

@ -13,20 +13,13 @@ import (
)
func TestGetModelConfig_Found(t *testing.T) {
cfg := (&Config{
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o"},
{ModelName: "other-model", Model: "anthropic/claude"},
{ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")},
},
}).WithSecurity(&SecurityConfig{ModelList: map[string]ModelSecurityEntry{
"test-model:0": {
APIKeys: []string{"key1"},
},
"other-model:0": {
APIKeys: []string{"key2"},
},
}})
}
result, err := cfg.GetModelConfig("test-model")
if err != nil {
@ -38,17 +31,11 @@ func TestGetModelConfig_Found(t *testing.T) {
}
func TestGetModelConfig_NotFound(t *testing.T) {
cfg := (&Config{
cfg := &Config{
ModelList: []*ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o"},
{ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")},
},
}).WithSecurity(&SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"test-model:0": {
APIKeys: []string{"key1"},
},
},
})
}
_, err := cfg.GetModelConfig("nonexistent")
if err == nil {
@ -68,25 +55,13 @@ func TestGetModelConfig_EmptyList(t *testing.T) {
}
func TestGetModelConfig_RoundRobin(t *testing.T) {
cfg := (&Config{
cfg := &Config{
ModelList: []*ModelConfig{
{ModelName: "lb-model", Model: "openai/gpt-4o-1"},
{ModelName: "lb-model", Model: "openai/gpt-4o-2"},
{ModelName: "lb-model", Model: "openai/gpt-4o-3"},
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")},
},
}).WithSecurity(&SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"lb-model:0": {
APIKeys: []string{"key1"},
},
"lb-model:1": {
APIKeys: []string{"key2"},
},
"lb-model:2": {
APIKeys: []string{"key3"},
},
},
})
}
// Test round-robin distribution
results := make(map[string]int)
@ -111,9 +86,9 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
cfg := &Config{
ModelList: []*ModelConfig{
{ModelName: "lb-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
{ModelName: "lb-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
{ModelName: "lb-model", Model: "openai/gpt-4o-3", apiKeys: []string{"key3"}},
{ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
{ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKeys: SimpleSecureStrings("key3")},
},
}
@ -139,8 +114,8 @@ func TestGetModelConfig_RoundRobinStartsFromFirstMatch(t *testing.T) {
func TestGetModelConfig_Concurrent(t *testing.T) {
cfg := &Config{
ModelList: []*ModelConfig{
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", apiKeys: []string{"key1"}},
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", apiKeys: []string{"key2"}},
{ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKeys: SimpleSecureStrings("key2")},
},
}

View file

@ -9,7 +9,7 @@ func TestExpandMultiKeyModels_SingleKey(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"single-key"},
APIKeys: SimpleSecureStrings("single-key"),
},
}
@ -38,7 +38,7 @@ func TestExpandMultiKeyModels_APIKeysOnly(t *testing.T) {
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
APIBase: "https://api.example.com",
apiKeys: []string{"key1", "key2", "key3"},
APIKeys: SimpleSecureStrings("key1", "key2", "key3"),
},
}
@ -91,7 +91,7 @@ func TestExpandMultiKeyModels_APIKeyAndAPIKeys(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key0", "key1", "key2"},
APIKeys: SimpleSecureStrings("key0", "key1", "key2"),
},
}
@ -117,7 +117,7 @@ func TestExpandMultiKeyModels_WithExistingFallbacks(t *testing.T) {
ModelName: "gpt-4",
Model: "openai/gpt-4o",
}
modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing
modelCfg.Fallbacks = []string{"claude-3"}
models := []*ModelConfig{modelCfg}
@ -143,7 +143,7 @@ func TestExpandMultiKeyModels_EmptyAPIKeys(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{},
APIKeys: SimpleSecureStrings(),
},
}
@ -164,7 +164,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key1", "key2", "key1"}, // Duplicate key1
APIKeys: SimpleSecureStrings("key1", "key2", "key1"), // Duplicate key1
},
}
@ -196,7 +196,7 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
RequestTimeout: 30,
ThinkingLevel: "high",
}
modelCfg.apiKeys = []string{"key0", "key1"} // Use internal field for multi-key testing
modelCfg.APIKeys = SimpleSecureStrings("key0", "key1") // Use internal field for multi-key testing
models := []*ModelConfig{modelCfg}
result := expandMultiKeyModels(models)
@ -237,7 +237,7 @@ func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"key1", "key2", "key3"},
APIKeys: SimpleSecureStrings("key1", "key2", "key3"),
},
}
@ -288,7 +288,7 @@ func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) {
{
ModelName: "gpt-4",
Model: "openai/gpt-4o",
apiKeys: []string{"single-key"},
APIKeys: SimpleSecureStrings("single-key"),
},
}

View file

@ -7,182 +7,26 @@ package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"github.com/caarlos0/env/v11"
"github.com/tencent-connect/botgo/log"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
SecurityConfigFile = ".security.yml"
)
func normalizeSecurityConfig(sec *SecurityConfig) *SecurityConfig {
if sec == nil {
sec = &SecurityConfig{}
}
if sec.ModelList == nil {
sec.ModelList = map[string]ModelSecurityEntry{}
}
if sec.Channels == nil {
sec.Channels = &ChannelsSecurity{}
}
if sec.Web == nil {
sec.Web = &WebToolsSecurity{}
}
if sec.Skills == nil {
sec.Skills = &SkillsSecurity{}
}
return sec
}
// SecurityConfig stores all sensitive data (API keys, tokens, secrets, passwords)
// This data is loaded from security.yml and kept separate from the main config
type SecurityConfig struct {
// Model API keys. Map key is model_name, can include suffix like "abc:0", "abc:1"
// for load balancing with same model_name. The suffix ":N" is used to distinguish
// multiple configs that share the same base model_name.
ModelList map[string]ModelSecurityEntry `yaml:"model_list"`
// Channel tokens/secrets
Channels *ChannelsSecurity `yaml:"channels,omitempty"`
Web *WebToolsSecurity `yaml:"web,omitempty"`
Skills *SkillsSecurity `yaml:"skills,omitempty"`
// cache for sensitive values and compiled regex (computed once)
sensitiveCache *SensitiveDataCache
}
// ModelSecurityEntry stores security data for a model
type ModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
}
// ChannelsSecurity stores channel-related security data
type ChannelsSecurity struct {
Telegram *TelegramSecurity `yaml:"telegram,omitempty"`
Feishu *FeishuSecurity `yaml:"feishu,omitempty"`
Discord *DiscordSecurity `yaml:"discord,omitempty"`
Weixin *WeixinSecurity `yaml:"weixin,omitempty"`
QQ *QQSecurity `yaml:"qq,omitempty"`
DingTalk *DingTalkSecurity `yaml:"dingtalk,omitempty"`
Slack *SlackSecurity `yaml:"slack,omitempty"`
Matrix *MatrixSecurity `yaml:"matrix,omitempty"`
LINE *LINESecurity `yaml:"line,omitempty"`
OneBot *OneBotSecurity `yaml:"onebot,omitempty"`
WeCom *WeComSecurity `yaml:"wecom,omitempty"`
Pico *PicoSecurity `yaml:"pico,omitempty"`
IRC *IRCSecurity `yaml:"irc,omitempty"`
}
type TelegramSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
}
type FeishuSecurity struct {
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `yaml:"encrypt_key,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `yaml:"verification_token,omitempty" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
}
type DiscordSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
}
type WeixinSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WEIXIN_TOKEN"`
}
type QQSecurity struct {
AppSecret string `yaml:"app_secret,omitempty" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
}
type DingTalkSecurity struct {
ClientSecret string `yaml:"client_secret,omitempty" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
}
type SlackSecurity struct {
BotToken string `yaml:"bot_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `yaml:"app_token,omitempty" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
}
type MatrixSecurity struct {
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
}
type LINESecurity struct {
ChannelSecret string `yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
ChannelAccessToken string `yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
}
type OneBotSecurity struct {
AccessToken string `yaml:"access_token,omitempty" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
}
type WeComSecurity struct {
Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_SECRET"`
}
type PicoSecurity struct {
Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
}
type IRCSecurity struct {
Password string `yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_IRC_PASSWORD"`
NickServPassword string `yaml:"nickserv_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_NICKSERV_PASSWORD"`
SASLPassword string `yaml:"sasl_password,omitempty" env:"PICOCLAW_CHANNELS_IRC_SASL_PASSWORD"`
}
type WebToolsSecurity struct {
Brave *BraveSecurity `yaml:"brave,omitempty"`
Tavily *TavilySecurity `yaml:"tavily,omitempty"`
Perplexity *PerplexitySecurity `yaml:"perplexity,omitempty"`
GLMSearch *GLMSearchSecurity `yaml:"glm_search,omitempty"`
BaiduSearch *BaiduSearchSecurity `yaml:"baidu_search,omitempty"`
}
type BraveSecurity struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type TavilySecurity struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type PerplexitySecurity struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type GLMSearchSecurity struct {
APIKey string `yaml:"api_key,omitempty"`
}
type BaiduSearchSecurity struct {
APIKey string `yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_BAIDU_API_KEY"`
}
type SkillsSecurity struct {
Github *GithubSecurity `yaml:"github,omitempty"`
ClawHub *ClawHubSecurity `yaml:"clawhub,omitempty"`
}
type GithubSecurity struct {
Token string `yaml:"token,omitempty"`
}
type ClawHubSecurity struct {
AuthToken string `yaml:"auth_token,omitempty"`
}
// securityPath returns the path to security.yml relative to the config file
func securityPath(configPath string) string {
configDir := filepath.Dir(configPath)
@ -191,34 +35,27 @@ func securityPath(configPath string) string {
// loadSecurityConfig loads the security configuration from security.yml
// Returns an empty SecurityConfig if the file doesn't exist
func loadSecurityConfig(securityPath string) (*SecurityConfig, error) {
func loadSecurityConfig(cfg *Config, securityPath string) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
data, err := os.ReadFile(securityPath)
if err != nil {
if os.IsNotExist(err) {
return normalizeSecurityConfig(nil), nil
return nil
}
return nil, fmt.Errorf("failed to read security config: %w", err)
return fmt.Errorf("failed to read security config: %w", err)
}
var sec SecurityConfig
if err := yaml.Unmarshal(data, &sec); err != nil {
return nil, fmt.Errorf("failed to parse security config: %w", err)
if err := yaml.Unmarshal(data, cfg); err != nil {
return fmt.Errorf("failed to parse security config: %w", err)
}
// No need to validate model_name format here - both formats are supported:
// - "model-name:0" (with index for multiple entries)
// - "model-name" (without index for single entry or default to index 0)
if err := env.Parse(&sec); err != nil {
log.Errorf("failed to parse environment variables: %v", err)
return nil, err
}
return normalizeSecurityConfig(&sec), nil
return nil
}
// saveSecurityConfig saves the security configuration to security.yml
func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
func saveSecurityConfig(securityPath string, sec *Config) error {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
@ -229,134 +66,6 @@ func saveSecurityConfig(securityPath string, sec *SecurityConfig) error {
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
}
// mergeSecurityConfig merges two SecurityConfig instances, preferring non-empty values from 'newer'.
// This is used during config migration to preserve existing security data while adding new entries.
func mergeSecurityConfig(existing, newer *SecurityConfig) *SecurityConfig {
if existing == nil {
return normalizeSecurityConfig(newer)
}
if newer == nil {
return normalizeSecurityConfig(existing)
}
result := normalizeSecurityConfig(nil)
// Merge ModelList: prefer newer if it has keys, otherwise use existing
for k, v := range existing.ModelList {
result.ModelList[k] = v
}
for k, v := range newer.ModelList {
if len(v.APIKeys) > 0 {
result.ModelList[k] = v
}
}
// Merge Channels
if existing.Channels != nil {
result.Channels = existing.Channels
}
if newer.Channels != nil {
if result.Channels == nil {
result.Channels = &ChannelsSecurity{}
}
mergeChannelsSecurity(result.Channels, newer.Channels)
}
// Merge Web
if existing.Web != nil {
result.Web = existing.Web
}
if newer.Web != nil {
if result.Web == nil {
result.Web = &WebToolsSecurity{}
}
mergeWebToolsSecurity(result.Web, newer.Web)
}
// Merge Skills
if existing.Skills != nil {
result.Skills = existing.Skills
}
if newer.Skills != nil {
if result.Skills == nil {
result.Skills = &SkillsSecurity{}
}
mergeSkillsSecurity(result.Skills, newer.Skills)
}
return result
}
func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
if src.Telegram != nil && src.Telegram.Token != "" {
dst.Telegram = src.Telegram
}
if src.Feishu != nil &&
(src.Feishu.AppSecret != "" || src.Feishu.EncryptKey != "" || src.Feishu.VerificationToken != "") {
dst.Feishu = src.Feishu
}
if src.Discord != nil && src.Discord.Token != "" {
dst.Discord = src.Discord
}
if src.Weixin != nil && src.Weixin.Token != "" {
dst.Weixin = src.Weixin
}
if src.QQ != nil && src.QQ.AppSecret != "" {
dst.QQ = src.QQ
}
if src.DingTalk != nil && src.DingTalk.ClientSecret != "" {
dst.DingTalk = src.DingTalk
}
if src.Slack != nil && (src.Slack.BotToken != "" || src.Slack.AppToken != "") {
dst.Slack = src.Slack
}
if src.Matrix != nil && src.Matrix.AccessToken != "" {
dst.Matrix = src.Matrix
}
if src.LINE != nil && (src.LINE.ChannelSecret != "" || src.LINE.ChannelAccessToken != "") {
dst.LINE = src.LINE
}
if src.OneBot != nil && src.OneBot.AccessToken != "" {
dst.OneBot = src.OneBot
}
if src.WeCom != nil && src.WeCom.Secret != "" {
dst.WeCom = src.WeCom
}
if src.Pico != nil && src.Pico.Token != "" {
dst.Pico = src.Pico
}
if src.IRC != nil && (src.IRC.Password != "" || src.IRC.NickServPassword != "" || src.IRC.SASLPassword != "") {
dst.IRC = src.IRC
}
}
func mergeWebToolsSecurity(dst, src *WebToolsSecurity) {
if src.Brave != nil && len(src.Brave.APIKeys) > 0 {
dst.Brave = src.Brave
}
if src.Tavily != nil && len(src.Tavily.APIKeys) > 0 {
dst.Tavily = src.Tavily
}
if src.Perplexity != nil && len(src.Perplexity.APIKeys) > 0 {
dst.Perplexity = src.Perplexity
}
if src.GLMSearch != nil && src.GLMSearch.APIKey != "" {
dst.GLMSearch = src.GLMSearch
}
if src.BaiduSearch != nil && src.BaiduSearch.APIKey != "" {
dst.BaiduSearch = src.BaiduSearch
}
}
func mergeSkillsSecurity(dst, src *SkillsSecurity) {
if src.Github != nil && src.Github.Token != "" {
dst.Github = src.Github
}
if src.ClawHub != nil && src.ClawHub.AuthToken != "" {
dst.ClawHub = src.ClawHub
}
}
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
// Computed once on first access via sync.Once.
@ -367,13 +76,13 @@ type SensitiveDataCache struct {
// SensitiveDataReplacer returns the strings.Replacer for filtering sensitive data.
// It is computed once on first access via sync.Once.
func (sec *SecurityConfig) SensitiveDataReplacer() *strings.Replacer {
func (sec *Config) SensitiveDataReplacer() *strings.Replacer {
sec.initSensitiveCache()
return sec.sensitiveCache.replacer
}
// initSensitiveCache initializes the sensitive data cache if not already done.
func (sec *SecurityConfig) initSensitiveCache() {
func (sec *Config) initSensitiveCache() {
if sec.sensitiveCache == nil {
sec.sensitiveCache = &SensitiveDataCache{}
}
@ -400,15 +109,14 @@ func (sec *SecurityConfig) initSensitiveCache() {
}
// collectSensitiveValues collects all sensitive strings from SecurityConfig using reflection.
func (sec *SecurityConfig) collectSensitiveValues() []string {
func (sec *Config) collectSensitiveValues() []string {
var values []string
collectSensitive(reflect.ValueOf(sec), &values)
return values
}
// collectSensitive recursively traverses the value and collects all non-empty string fields.
// collectSensitive recursively traverses the value and collects SecureString/SecureStrings values.
func collectSensitive(v reflect.Value, values *[]string) {
// Dereference pointers/interfaces to get the underlying value
for v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
if v.IsNil() {
return
@ -416,27 +124,53 @@ func collectSensitive(v reflect.Value, values *[]string) {
v = v.Elem()
}
t := v.Type()
// SecureString: collect via String() method (defined on *SecureString)
if t == reflect.TypeOf(SecureString{}) {
result := v.Addr().MethodByName("String").Call(nil)
if len(result) > 0 {
if s := result[0].String(); s != "" {
*values = append(*values, s)
}
}
return
}
// SecureStrings ([]*SecureString): iterate and collect each element
if t == reflect.TypeOf(SecureStrings{}) {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
for elem.Kind() == reflect.Ptr || elem.Kind() == reflect.Interface {
if elem.IsNil() {
elem = reflect.Value{}
break
}
elem = elem.Elem()
}
if elem.IsValid() && elem.Type() == reflect.TypeOf(SecureString{}) {
result := elem.Addr().MethodByName("String").Call(nil)
if len(result) > 0 {
if s := result[0].String(); s != "" {
*values = append(*values, s)
}
}
}
}
return
}
switch v.Kind() {
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := v.Type().Field(i)
if !fieldType.IsExported() {
if !t.Field(i).IsExported() {
continue
}
collectSensitive(field, values)
}
case reflect.String:
if v.String() != "" {
*values = append(*values, v.String())
collectSensitive(v.Field(i), values)
}
case reflect.Slice:
if v.Type().Elem().Kind() == reflect.String {
for i := 0; i < v.Len(); i++ {
if s := v.Index(i).String(); s != "" {
*values = append(*values, s)
}
}
for i := 0; i < v.Len(); i++ {
collectSensitive(v.Index(i), values)
}
case reflect.Map:
for _, key := range v.MapKeys() {
@ -444,3 +178,234 @@ func collectSensitive(v reflect.Value, values *[]string) {
}
}
}
const (
notHere = `"[NOT_HERE]"`
)
// SecureStrings is a slice of SecureString
type SecureStrings []*SecureString
// Values returns the decrypted/resolved values
func (s *SecureStrings) Values() []string {
if s == nil {
return nil
}
keys := make([]string, len(*s))
for i, k := range *s {
keys[i] = k.String()
}
return unique(keys)
}
func SimpleSecureStrings(val ...string) SecureStrings {
val = unique(val)
vv := make(SecureStrings, len(val))
for i, s := range val {
vv[i] = NewSecureString(s)
}
return vv
}
// unique returns a new slice with duplicate elements removed.
func unique[T comparable](input []T) []T {
m := make(map[T]struct{})
var result []T
for _, v := range input {
if _, ok := m[v]; !ok {
m[v] = struct{}{}
result = append(result, v)
}
}
return result
}
func (s SecureStrings) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v []*SecureString
err := json.Unmarshal(value, &v)
if err != nil {
return err
}
*s = v
return nil
}
// SecureString the string value that can be decrypted or resolved
//
//nolint:recvcheck
type SecureString struct {
resolved string // Decrypted/resolved value returned by String()
raw string // Persisted raw value (enc://, file://, or plaintext)
}
func callerFromYaml() bool {
_, file, _, ok := runtime.Caller(2)
if ok {
d := filepath.Dir(file)
// check the caller is from yaml.v
if !strings.Contains(d, "yaml.v") {
return true
}
}
return false
}
// IsZero returns true if the SecureString is empty
// if caller not yaml, just return true for prevent marshal this field
func (s SecureString) IsZero() bool {
if callerFromYaml() {
return true
}
return s.resolved == ""
}
func NewSecureString(value string) *SecureString {
s := &SecureString{}
if err := s.fromRaw(value); err != nil {
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
}
return s
}
func (s *SecureString) String() string {
if s == nil {
return ""
}
return s.resolved
}
func (s *SecureString) Set(value string) *SecureString {
s.resolved = value
s.raw = ""
return s
}
func (s SecureString) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureString) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
return s.fromRaw(v)
}
func (s SecureString) MarshalYAML() (any, error) {
// Preserve raw value if it is already a reference (enc:// or file://)
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
return s.raw, nil
}
// If resolved is a reference format (e.g. set via Set), copy back to raw
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
s.raw = s.resolved
return s.raw, nil
}
// Try to encrypt the resolved value
if passphrase := credential.PassphraseProvider(); passphrase != "" {
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
if err != nil {
logger.Errorf("Encrypt error: %v", err)
return nil, err
}
s.raw = encrypted
} else {
s.raw = s.resolved
}
return s.raw, nil
}
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
return s.fromRaw(value.Value)
}
func (s *SecureString) fromRaw(v string) error {
s.raw = v
vv, err := resolveKey(v)
if err != nil {
return err
}
s.resolved = vv
return nil
}
var (
secResolverMu sync.RWMutex
secResolver *credential.Resolver
)
func updateResolver(path string) {
secResolverMu.Lock()
defer secResolverMu.Unlock()
secResolver = credential.NewResolver(path)
}
func resolveKey(v string) (string, error) {
secResolverMu.RLock()
resolver := secResolver
secResolverMu.RUnlock()
if resolver == nil {
resolver = credential.NewResolver("")
}
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
decrypted, err := resolver.Resolve(v)
if err != nil {
logger.Errorf("Resolve error: %v", err)
return "", err
}
return decrypted, nil
}
return v, nil
}
func (s *SecureString) UnmarshalText(text []byte) error {
v := string(text)
return s.fromRaw(v)
}
type SecureModelList []*ModelConfig
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
mm := make(map[string]*ModelConfig)
if err := value.Decode(&mm); err != nil {
logger.Errorf("Decode error: %v", err)
return err
}
nameList := toNameIndex(*v)
for i, m := range *v {
sec := mm[nameList[i]]
if sec == nil {
sec = mm[m.ModelName]
}
if sec != nil {
m.APIKeys = sec.APIKeys
}
}
return nil
}
func (v SecureModelList) MarshalYAML() (any, error) {
type onlySecureData struct {
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
}
mm := make(map[string]onlySecureData)
nameList := toNameIndex(v)
for i, m := range v {
mm[nameList[i]] = onlySecureData{
APIKeys: m.APIKeys,
}
}
return mm, nil
}

View file

@ -66,7 +66,7 @@ func TestSecurityConfigIntegration(t *testing.T) {
"web": {
"brave": {
"enabled": true,
"api_key": "BSA-from-config-json-direct"
"api_keys": ["BSA-from-config-json-direct"]
}
},
"skills": {
@ -91,11 +91,6 @@ channels:
telegram:
token: "token-from-security-yml"
web:
brave:
api_keys:
- "BSA-from-security-yml"
skills:
github:
token: "ghp-from-security-yml"`
@ -110,16 +105,18 @@ skills:
// Verify model API key from config.json takes precedence
assert.Equal(t, 1, len(cfg.ModelList))
assert.Equal(t, "test-model", cfg.ModelList[0].ModelName)
assert.Equal(t, "sk-from-config-json-direct", cfg.ModelList[0].apiKeys[0])
assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKey())
// Verify channel token from config.json takes precedence
assert.Equal(t, "token-from-config-json-direct", cfg.Channels.Telegram.token)
assert.Equal(t, "token-from-security-yml", cfg.Channels.Telegram.Token.String())
assert.Equal(t, "sk-from-security-yml", cfg.ModelList[0].APIKeys[0].String())
// Verify web tool API key from config.json takes precedence
assert.Equal(t, "BSA-from-config-json-direct", cfg.Tools.Web.Brave.APIKey())
// Verify skills token from config.json takes precedence
assert.Equal(t, "ghp-from-config-json-direct", cfg.Tools.Skills.Github.token)
// Verify skills token is resolved
assert.Equal(t, "ghp-from-security-yml", cfg.Tools.Skills.Github.Token.String())
})
}
@ -355,66 +352,66 @@ skills:
// Verify Channel tokens via Key() methods
// Telegram
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token())
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token())
assert.Equal(t, "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", cfg.Channels.Telegram.Token.String())
t.Logf("Telegram Token(): %s", cfg.Channels.Telegram.Token.String())
// Feishu
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret())
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey())
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken())
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret())
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey())
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken())
assert.Equal(t, "feishu_test_app_secret", cfg.Channels.Feishu.AppSecret.String())
assert.Equal(t, "feishu_test_encrypt_key", cfg.Channels.Feishu.EncryptKey.String())
assert.Equal(t, "feishu_test_verification_token", cfg.Channels.Feishu.VerificationToken.String())
t.Logf("Feishu AppSecret(): %s", cfg.Channels.Feishu.AppSecret.String())
t.Logf("Feishu EncryptKey(): %s", cfg.Channels.Feishu.EncryptKey.String())
t.Logf("Feishu VerificationToken(): %s", cfg.Channels.Feishu.VerificationToken.String())
// Discord
assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token())
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token())
assert.Equal(t, "discord_test_bot_token_xyz", cfg.Channels.Discord.Token.String())
t.Logf("Discord Token(): %s", cfg.Channels.Discord.Token.String())
// DingTalk
assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret())
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret())
assert.Equal(t, "dingtalk_test_client_secret", cfg.Channels.DingTalk.ClientSecret.String())
t.Logf("DingTalk ClientSecret(): %s", cfg.Channels.DingTalk.ClientSecret.String())
// Slack
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken())
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken())
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken())
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken())
assert.Equal(t, "xoxb-slack-bot-token-123", cfg.Channels.Slack.BotToken.String())
assert.Equal(t, "xapp-slack-app-token-456", cfg.Channels.Slack.AppToken.String())
t.Logf("Slack BotToken(): %s", cfg.Channels.Slack.BotToken.String())
t.Logf("Slack AppToken(): %s", cfg.Channels.Slack.AppToken.String())
// Matrix
assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken())
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken())
assert.Equal(t, "matrix_test_access_token", cfg.Channels.Matrix.AccessToken.String())
t.Logf("Matrix AccessToken(): %s", cfg.Channels.Matrix.AccessToken.String())
// LINE
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret())
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken())
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret())
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken())
assert.Equal(t, "line_test_channel_secret", cfg.Channels.LINE.ChannelSecret.String())
assert.Equal(t, "line_test_channel_access_token", cfg.Channels.LINE.ChannelAccessToken.String())
t.Logf("LINE ChannelSecret(): %s", cfg.Channels.LINE.ChannelSecret.String())
t.Logf("LINE ChannelAccessToken(): %s", cfg.Channels.LINE.ChannelAccessToken.String())
// OneBot
assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken())
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
assert.Equal(t, "onebot_test_access_token", cfg.Channels.OneBot.AccessToken.String())
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken.String())
// WeCom
assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID)
assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret())
assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret.String())
t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID)
t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret())
t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret.String())
// Pico
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token())
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token.String())
t.Logf("Pico Token(): %s", cfg.Channels.Pico.Token.String())
// IRC
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password())
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword())
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword())
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password())
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword())
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword())
assert.Equal(t, "irc_test_password", cfg.Channels.IRC.Password.String())
assert.Equal(t, "irc_test_nickserv_password", cfg.Channels.IRC.NickServPassword.String())
assert.Equal(t, "irc_test_sasl_password", cfg.Channels.IRC.SASLPassword.String())
t.Logf("IRC Password(): %s", cfg.Channels.IRC.Password.String())
t.Logf("IRC NickServPassword(): %s", cfg.Channels.IRC.NickServPassword.String())
t.Logf("IRC SASLPassword(): %s", cfg.Channels.IRC.SASLPassword.String())
// QQ
assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret())
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret())
assert.Equal(t, "qq_test_app_secret", cfg.Channels.QQ.AppSecret.String())
t.Logf("QQ AppSecret(): %s", cfg.Channels.QQ.AppSecret.String())
// Verify Web tool API keys
assert.Equal(t, "BSA-brave-from-file-67890", cfg.Tools.Web.Brave.APIKey())
@ -427,15 +424,15 @@ skills:
t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey())
// GLM Search - Note: GLM uses SetAPIKey (lowercase) internally
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey())
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey())
t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String())
assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String())
// Verify Skills tokens
assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token())
t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token())
assert.Equal(t, "ghp-github-from-file-abc123", cfg.Tools.Skills.Github.Token.String())
t.Logf("Github Token(): %s", cfg.Tools.Skills.Github.Token.String())
assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken())
assert.Equal(t, "clawhub-auth-token-from-file", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String())
t.Logf("ClawHub AuthToken(): %s", cfg.Tools.Skills.Registries.ClawHub.AuthToken.String())
t.Log("All security keys are successfully accessible via their respective Key() methods")
})

View file

@ -6,23 +6,29 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/caarlos0/env/v11"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
)
func TestSecurityConfig(t *testing.T) {
t.Run("LoadNonExistent", func(t *testing.T) {
sec, err := loadSecurityConfig("/nonexistent/.security.yml")
sec := &Config{}
err := loadSecurityConfig(sec, "/nonexistent/.security.yml")
require.NoError(t, err)
assert.NotNil(t, sec)
assert.Empty(t, sec.ModelList)
assert.NotNil(t, sec.Channels)
assert.NotNil(t, sec.Web)
assert.NotNil(t, sec.Skills)
assert.NotNil(t, sec.Tools.Web)
assert.NotNil(t, sec.Tools.Skills)
})
}
@ -53,41 +59,302 @@ func TestSecurityPath(t *testing.T) {
}
func TestSaveAndLoadSecurityConfig(t *testing.T) {
t.Run("test for securestring", func(t *testing.T) {
type testStruct struct {
Secret SecureString `json:"secret,omitzero" yaml:"secret,omitempty" env:"TEST_SECURE_STRING"`
}
s := testStruct{Secret: *NewSecureString("test")}
out, err := yaml.Marshal(s) // 直接对 SecureString 进行序列化
require.NoError(t, err)
t.Logf("output: %v", string(out))
assert.Equal(t, "secret: test\n", string(out))
out, err = json.Marshal(s)
require.NoError(t, err)
t.Logf("output: %v", string(out))
assert.Equal(t, "{}", string(out))
})
tmpDir := t.TempDir()
secPath := filepath.Join(tmpDir, SecurityConfigFile)
original := &SecurityConfig{
ModelList: map[string]ModelSecurityEntry{
"model1:0": {
APIKeys: []string{"key1", "key2"},
original := &Config{
ModelList: SecureModelList{
{
ModelName: "model1",
Model: "test/model",
APIBase: "api.example.com",
APIKeys: SecureStrings{NewSecureString("key1"), NewSecureString("key2")},
},
{
ModelName: "model2",
Model: "test/model2",
APIBase: "api2.example.com",
APIKeys: SecureStrings{NewSecureString("model2_key")},
},
},
Channels: &ChannelsSecurity{
Telegram: &TelegramSecurity{
Token: "telegram-token",
Tools: ToolsConfig{
Web: WebToolsConfig{
Brave: BraveConfig{
Enabled: true,
APIKeys: SecureStrings{NewSecureString("brave_key")},
},
},
Skills: SkillsToolsConfig{
Github: SkillsGithubConfig{
Token: *NewSecureString("github_token"),
Proxy: "test proxy",
},
},
},
Web: &WebToolsSecurity{
Brave: &BraveSecurity{
APIKeys: []string{"brave-api-key"},
Channels: ChannelsConfig{
Telegram: TelegramConfig{
Enabled: true,
Token: *NewSecureString("telegram_token"),
},
Feishu: FeishuConfig{
Enabled: true,
AppID: "feishu_app_id",
AppSecret: *NewSecureString("feishu_app_secret"),
},
Discord: DiscordConfig{
Enabled: true,
Token: *NewSecureString("discord_token"),
},
QQ: QQConfig{
Enabled: true,
AppSecret: *NewSecureString("qq_app_secret"),
},
PicoClient: PicoClientConfig{
Enabled: true,
Token: *NewSecureString("pico_client_token"),
},
},
}
// Save
err := saveSecurityConfig(secPath, original)
require.NoError(t, err)
t.Run("test for original", func(t *testing.T) {
assert.Equal(t, 2, len(original.ModelList[0].APIKeys))
assert.Equal(t, "key1", original.ModelList[0].APIKeys[0].String())
})
// Verify file was created with correct permissions
info, err := os.Stat(secPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode())
cfg2 := &Config{}
t.Run("test for json", func(t *testing.T) {
marshal, err := json.Marshal(original)
require.NoError(t, err)
t.Logf("json: %s", string(marshal))
assert.Contains(t, string(marshal), "\"api_keys\"")
assert.Contains(t, string(marshal), notHere)
// Load
loaded, err := loadSecurityConfig(secPath)
require.NoError(t, err)
err = json.Unmarshal(marshal, cfg2)
require.NoError(t, err)
require.Equal(t, 2, len(cfg2.ModelList))
assert.Empty(t, cfg2.ModelList[0].APIKeys)
assert.Empty(t, cfg2.ModelList[1].APIKeys)
})
assert.Equal(t, original.ModelList, loaded.ModelList)
assert.Equal(t, original.Channels.Telegram.Token, loaded.Channels.Telegram.Token)
assert.EqualValues(t, original.Web.Brave.APIKeys, loaded.Web.Brave.APIKeys)
t.Run("test for save yaml", func(t *testing.T) {
// Save
err := saveSecurityConfig(secPath, original)
require.NoError(t, err)
// Verify file was created with correct permissions
info, err := os.Stat(secPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), info.Mode())
file, err := os.ReadFile(secPath)
assert.NoError(t, err)
t.Logf("%s", string(file))
yamlOutput := `channels:
telegram:
token: telegram_token
feishu:
app_secret: feishu_app_secret
discord:
token: discord_token
qq:
app_secret: qq_app_secret
pico_client:
token: pico_client_token
model_list:
model1:0:
api_keys:
- key1
- key2
model2:0:
api_keys:
- model2_key
web:
brave:
api_keys:
- brave_key
skills:
github:
token: github_token
`
assert.Equal(t, yamlOutput, string(file))
err = os.WriteFile(secPath, []byte(yamlOutput), 0o600)
require.NoError(t, err)
})
t.Run("test for load yaml", func(t *testing.T) {
// Load
cfg := cfg2
err := loadSecurityConfig(cfg, secPath)
require.NoError(t, err)
t.Logf("%+v", cfg)
t.Logf("%+v", cfg.Tools.Web.Brave.APIKeys)
t.Logf("%+v", cfg.Tools.Skills.Github.Token)
require.EqualValues(t, 2, len(cfg.ModelList))
assert.Equal(t, "key1", cfg.ModelList[0].APIKeys[0].String())
assert.Equal(t, "key2", cfg.ModelList[0].APIKeys[1].String())
assert.Equal(t, "model2_key", cfg.ModelList[1].APIKeys[0].String())
assert.EqualValues(t, original.Tools.Web.Brave.APIKeys, cfg.Tools.Web.Brave.APIKeys)
})
t.Run("test for env overwrite", func(t *testing.T) {
// This will throw a COMPILER ERROR if SecureString doesn't
// correctly implement the yaml.Marshaler interface.
var _ yaml.Marshaler = (*SecureString)(nil)
// If you are using Value types in your config, also check:
var _ yaml.Marshaler = SecureString{}
t.Setenv("PICOCLAW_CHANNELS_QQ_APP_SECRET", "qq_app_secret_env")
t.Setenv("PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS", "brave_key_env,abc")
err2 := env.Parse(cfg2)
require.NoError(t, err2)
assert.Equal(t, "qq_app_secret_env", cfg2.Channels.QQ.AppSecret.raw)
assert.Equal(t, "brave_key_env", cfg2.Tools.Web.Brave.APIKeys[0].raw)
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
})
}
func TestLoadSecurityValue(t *testing.T) {
type valueStruct struct {
Url string `json:"url,omitempty" yaml:"-"`
Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
}
type testStruct struct {
Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
v1 := &testStruct{
Pico: &valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
}
bytes, err := yaml.Marshal(v1)
assert.NoError(t, err)
jsonBytes, err := json.Marshal(v1)
assert.NoError(t, err)
const want = `pico:
token: token1
api_keys:
- api-key1
- api-key2
`
const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
v0 := &testStruct{}
err = json.Unmarshal([]byte(jsonPost), v0)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v0.Pico.Url)
assert.Equal(t, "token0", v0.Pico.Token.String())
const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
assert.Equal(t, want, string(bytes))
assert.Equal(t, jsonWant, string(jsonBytes))
v2 := &testStruct{}
err = json.Unmarshal(jsonBytes, v2)
assert.NoError(t, err)
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v2.Pico.Url)
if v2.Pico.Token != nil {
assert.Equal(t, "token1", v2.Pico.Token.String())
assert.Equal(t, "token1", v2.Pico.Token.raw)
}
v2.Pico.Token = NewSecureString("token1")
v2.Pico.Token.raw = "abc"
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "token1", v2.Pico.Token.raw)
os.Setenv("PICO_TOKEN", "token_env")
err = env.Parse(v2)
assert.NoError(t, err)
assert.NotNil(t, v2.Pico.Token)
assert.Equal(t, "token1", v2.Pico.Token.String())
v3 := &testStruct{Pico: &valueStruct{}}
err = env.Parse(v3)
assert.NoError(t, err)
if v3.Pico.Token != nil {
assert.Equal(t, "token_env", v3.Pico.Token.String())
}
type toolsStruct struct {
Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
type testStruct2 struct {
Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
}
v4 := &testStruct2{
Tools: toolsStruct{
Pico: valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
},
}
bytes, err = yaml.Marshal(v4)
assert.NoError(t, err)
assert.Equal(t, want, string(bytes))
jsonBytes, err = json.Marshal(v4)
assert.NoError(t, err)
assert.Equal(
t,
`{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
string(jsonBytes),
)
v5 := &testStruct2{}
err = json.Unmarshal(jsonBytes, v5)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
err = yaml.Unmarshal(bytes, v5)
assert.NoError(t, err)
assert.NotNil(t, v5.Tools.Pico.Token)
assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
const passphrase = "test-passphrase-32bytes-long-ok!"
t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
t.Setenv(credential.PassphraseEnvVar, passphrase)
v5.Tools.Pico.Token.Set("newtoken1")
v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
bytes, err = yaml.Marshal(v5)
assert.NoError(t, err)
t.Logf("yaml: %s", string(bytes))
v6 := &testStruct2{}
err = yaml.Unmarshal(bytes, v6)
assert.NoError(t, err)
assert.NotNil(t, v6.Tools.Pico.Token)
assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
}

View file

@ -75,12 +75,13 @@ const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH"
const picoclawHome = "PICOCLAW_HOME"
const (
fileScheme = "file://"
encScheme = "enc://"
hkdfInfo = "picoclaw-credential-v1"
saltLen = 16
nonceLen = 12
keyLen = 32
FileScheme = "file://"
EncScheme = "enc://"
hkdfInfo = "picoclaw-credential-v1"
saltLen = 16
nonceLen = 12
keyLen = 32
)
// Resolver resolves raw credential strings for model_list api_key fields.
@ -112,8 +113,8 @@ func (r *Resolver) Resolve(raw string) (string, error) {
return "", nil
}
if strings.HasPrefix(raw, fileScheme) {
fileName := strings.TrimSpace(strings.TrimPrefix(raw, fileScheme))
if strings.HasPrefix(raw, FileScheme) {
fileName := strings.TrimSpace(strings.TrimPrefix(raw, FileScheme))
if fileName == "" {
return "", fmt.Errorf("credential: file:// reference has no filename")
}
@ -144,7 +145,7 @@ func (r *Resolver) Resolve(raw string) (string, error) {
return value, nil
}
if strings.HasPrefix(raw, encScheme) {
if strings.HasPrefix(raw, EncScheme) {
return resolveEncrypted(raw)
}
@ -161,7 +162,7 @@ func resolveEncrypted(raw string) (string, error) {
sshKeyPath := pickSSHKeyPath("") // override="": consult env then auto-detect
b64 := strings.TrimPrefix(raw, encScheme)
b64 := strings.TrimPrefix(raw, EncScheme)
blob, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", fmt.Errorf("credential: enc:// invalid base64: %w", err)
@ -234,7 +235,7 @@ func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) {
blob = append(blob, salt...)
blob = append(blob, nonce...)
blob = append(blob, ciphertext...)
return encScheme + base64.StdEncoding.EncodeToString(blob), nil
return EncScheme + base64.StdEncoding.EncodeToString(blob), nil
}
// isWithinDir reports whether path is contained within (or equal to) dir.

View file

@ -1029,7 +1029,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
Proxy: c.Telegram.Proxy,
}
if c.Telegram.Token != "" {
tc.SetToken(c.Telegram.Token)
tc.Token = *config.NewSecureString(c.Telegram.Token)
}
return tc
}(),
@ -1039,13 +1039,13 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
AppID: c.Feishu.AppID,
}
if c.Feishu.AppSecret != "" {
fc.SetAppSecret(c.Feishu.AppSecret)
fc.AppSecret = *config.NewSecureString(c.Feishu.AppSecret)
}
if c.Feishu.EncryptKey != "" {
fc.SetEncryptKey(c.Feishu.EncryptKey)
fc.EncryptKey = *config.NewSecureString(c.Feishu.EncryptKey)
}
if c.Feishu.VerificationToken != "" {
fc.SetVerificationToken(c.Feishu.VerificationToken)
fc.VerificationToken = *config.NewSecureString(c.Feishu.VerificationToken)
}
return fc
}(),
@ -1055,7 +1055,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
MentionOnly: c.Discord.MentionOnly,
}
if c.Discord.Token != "" {
dc.SetToken(c.Discord.Token)
dc.Token = *config.NewSecureString(c.Discord.Token)
}
return dc
}(),
@ -1070,7 +1070,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
AppID: c.QQ.AppID,
}
if c.QQ.AppSecret != "" {
qc.SetAppSecret(c.QQ.AppSecret)
qc.AppSecret = *config.NewSecureString(c.QQ.AppSecret)
}
return qc
}(),
@ -1080,7 +1080,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
ClientID: c.DingTalk.ClientID,
}
if c.DingTalk.ClientSecret != "" {
dt.SetClientSecret(c.DingTalk.ClientSecret)
dt.ClientSecret = *config.NewSecureString(c.DingTalk.ClientSecret)
}
return dt
}(),
@ -1089,10 +1089,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
Enabled: c.Slack.Enabled,
}
if c.Slack.BotToken != "" {
sc.SetBotToken(c.Slack.BotToken)
sc.BotToken = *config.NewSecureString(c.Slack.BotToken)
}
if c.Slack.AppToken != "" {
sc.SetAppToken(c.Slack.AppToken)
sc.AppToken = *config.NewSecureString(c.Slack.AppToken)
}
return sc
}(),
@ -1105,7 +1105,7 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
JoinOnInvite: true,
}
if c.Matrix.AccessToken != "" {
mc.SetAccessToken(c.Matrix.AccessToken)
mc.AccessToken = *config.NewSecureString(c.Matrix.AccessToken)
}
return mc
}(),
@ -1117,10 +1117,10 @@ func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig {
WebhookPath: c.LINE.WebhookPath,
}
if c.LINE.ChannelSecret != "" {
lc.SetChannelSecret(c.LINE.ChannelSecret)
lc.ChannelSecret = *config.NewSecureString(c.LINE.ChannelSecret)
}
if c.LINE.ChannelAccessToken != "" {
lc.SetChannelAccessToken(c.LINE.ChannelAccessToken)
lc.ChannelAccessToken = *config.NewSecureString(c.LINE.ChannelAccessToken)
}
return lc
}(),

View file

@ -711,8 +711,8 @@ func TestToStandardConfig(t *testing.T) {
if !stdCfg.Channels.Telegram.Enabled {
t.Error("telegram should be enabled")
}
if stdCfg.Channels.Telegram.Token() != "test-token" {
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token())
if stdCfg.Channels.Telegram.Token.String() != "test-token" {
t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token.String())
}
if stdCfg.Gateway.Port != 8080 {

View file

@ -43,7 +43,9 @@ var (
reBlankLines = regexp.MustCompile(`\n{3,}`)
// DuckDuckGo result extraction
reDDGLink = regexp.MustCompile(`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`)
reDDGLink = regexp.MustCompile(
`<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>`,
)
reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`)
)
@ -86,7 +88,122 @@ func (it *APIKeyIterator) Next() (string, bool) {
}
type SearchProvider interface {
Search(ctx context.Context, query string, count int) (string, error)
Search(ctx context.Context, query string, count int, rangeCode string) (string, error)
}
func normalizeSearchRange(raw string) (string, error) {
rangeCode := strings.ToLower(strings.TrimSpace(raw))
switch rangeCode {
case "", "d", "w", "m", "y":
return rangeCode, nil
default:
return "", fmt.Errorf("range must be one of: d, w, m, y")
}
}
func mapBraveFreshness(rangeCode string) string {
switch rangeCode {
case "d":
return "pd"
case "w":
return "pw"
case "m":
return "pm"
case "y":
return "py"
default:
return ""
}
}
func mapTavilyTimeRange(rangeCode string) string {
switch rangeCode {
case "d":
return "day"
case "w":
return "week"
case "m":
return "month"
case "y":
return "year"
default:
return ""
}
}
func mapPerplexityRecencyFilter(rangeCode string) string {
switch rangeCode {
case "d":
return "day"
case "w":
return "week"
case "m":
return "month"
case "y":
return "year"
default:
return ""
}
}
func mapDuckDuckGoDateFilter(rangeCode string) string {
switch rangeCode {
case "d":
return "d"
case "w":
return "w"
case "m":
return "m"
case "y":
return "t"
default:
return ""
}
}
func mapSearXNGTimeRange(rangeCode string) string {
switch rangeCode {
case "d":
return "day"
case "w":
return "week"
case "m":
return "month"
case "y":
return "year"
default:
return ""
}
}
func mapGLMRecencyFilter(rangeCode string) string {
switch rangeCode {
case "d":
return "oneDay"
case "w":
return "oneWeek"
case "m":
return "oneMonth"
case "y":
return "oneYear"
default:
return "noLimit"
}
}
func mapBaiduRecencyFilter(rangeCode string) string {
switch rangeCode {
case "d", "w":
// Baidu does not expose a day-level filter. Use the closest supported
// window to keep recency bias instead of silently dropping the filter.
return "week"
case "m":
return "month"
case "y":
return "year"
default:
return ""
}
}
type BraveSearchProvider struct {
@ -95,9 +212,17 @@ type BraveSearchProvider struct {
client *http.Client
}
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *BraveSearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
if freshness := mapBraveFreshness(rangeCode); freshness != "" {
searchURL += "&freshness=" + url.QueryEscape(freshness)
}
var lastErr error
iter := p.keyPool.NewIterator()
@ -186,7 +311,12 @@ type TavilySearchProvider struct {
client *http.Client
}
func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *TavilySearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://api.tavily.com/search"
@ -210,6 +340,9 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
"include_raw_content": false,
"max_results": count,
}
if timeRange := mapTavilyTimeRange(rangeCode); timeRange != "" {
payload["time_range"] = timeRange
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
@ -289,8 +422,16 @@ type DuckDuckGoSearchProvider struct {
client *http.Client
}
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *DuckDuckGoSearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
if dateFilter := mapDuckDuckGoDateFilter(rangeCode); dateFilter != "" {
searchURL += "&df=" + url.QueryEscape(dateFilter)
}
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
@ -313,7 +454,11 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
return p.extractResults(string(body), count, query)
}
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
func (p *DuckDuckGoSearchProvider) extractResults(
html string,
count int,
query string,
) (string, error) {
// Simple regex based extraction for DDG HTML
// Strategy: Find all result containers or key anchors directly
@ -381,7 +526,12 @@ type PerplexitySearchProvider struct {
client *http.Client
}
func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *PerplexitySearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := "https://api.perplexity.ai/chat/completions"
var lastErr error
@ -401,19 +551,31 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
"content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.",
},
{
"role": "user",
"content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count),
"role": "user",
"content": fmt.Sprintf(
"Search for: %s. Provide up to %d relevant results.",
query,
count,
),
},
},
"max_tokens": 1000,
}
if recencyFilter := mapPerplexityRecencyFilter(rangeCode); recencyFilter != "" {
payload["search_recency_filter"] = recencyFilter
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
req, err := http.NewRequestWithContext(
ctx,
"POST",
searchURL,
strings.NewReader(string(payloadBytes)),
)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
@ -463,7 +625,11 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
return fmt.Sprintf("No results for: %s", query), nil
}
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
return fmt.Sprintf(
"Results for: %s (via Perplexity)\n%s",
query,
searchResp.Choices[0].Message.Content,
), nil
}
return "", fmt.Errorf("all api keys failed, last error: %w", lastErr)
@ -473,10 +639,18 @@ type SearXNGSearchProvider struct {
baseURL string
}
func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *SearXNGSearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general",
strings.TrimSuffix(p.baseURL, "/"),
url.QueryEscape(query))
if timeRange := mapSearXNGTimeRange(rangeCode); timeRange != "" {
searchURL += "&time_range=" + url.QueryEscape(timeRange)
}
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
@ -539,7 +713,12 @@ type GLMSearchProvider struct {
client *http.Client
}
func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *GLMSearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
@ -552,6 +731,9 @@ func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int)
"count": count,
"content_size": "medium",
}
if recencyFilter := mapGLMRecencyFilter(rangeCode); recencyFilter != "" {
payload["search_recency_filter"] = recencyFilter
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
@ -620,7 +802,12 @@ type BaiduSearchProvider struct {
client *http.Client
}
func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
func (p *BaiduSearchProvider) Search(
ctx context.Context,
query string,
count int,
rangeCode string,
) (string, error) {
searchURL := p.baseURL
if searchURL == "" {
searchURL = "https://qianfan.baidubce.com/v2/ai_search/web_search"
@ -636,6 +823,9 @@ func (p *BaiduSearchProvider) Search(ctx context.Context, query string, count in
"search_source": "baidu_search_v2",
"resource_type_filter": []map[string]any{{"type": "web", "top_k": count}},
}
if recencyFilter := mapBaiduRecencyFilter(rangeCode); recencyFilter != "" {
payload["search_recency_filter"] = recencyFilter
}
bodyBytes, err := json.Marshal(payload)
if err != nil {
@ -729,7 +919,7 @@ type WebSearchToolOptions struct {
func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
var provider SearchProvider
maxResults := 5
maxResults := 10
// Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > Baidu Search > GLM Search
if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 {
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
@ -742,7 +932,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
client: client,
}
if opts.PerplexityMaxResults > 0 {
maxResults = opts.PerplexityMaxResults
maxResults = min(opts.PerplexityMaxResults, 10)
}
} else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
@ -751,12 +941,12 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
}
provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
maxResults = min(opts.BraveMaxResults, 10)
}
} else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" {
provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL}
if opts.SearXNGMaxResults > 0 {
maxResults = opts.SearXNGMaxResults
maxResults = min(opts.SearXNGMaxResults, 10)
}
} else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
@ -770,7 +960,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
client: client,
}
if opts.TavilyMaxResults > 0 {
maxResults = opts.TavilyMaxResults
maxResults = min(opts.TavilyMaxResults, 10)
}
} else if opts.DuckDuckGoEnabled {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
@ -779,7 +969,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
}
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
maxResults = min(opts.DuckDuckGoMaxResults, 10)
}
} else if opts.BaiduSearchEnabled && opts.BaiduSearchAPIKey != "" {
client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout)
@ -793,7 +983,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
client: client,
}
if opts.BaiduSearchMaxResults > 0 {
maxResults = opts.BaiduSearchMaxResults
maxResults = min(opts.BaiduSearchMaxResults, 10)
}
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
@ -812,7 +1002,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
client: client,
}
if opts.GLMSearchMaxResults > 0 {
maxResults = opts.GLMSearchMaxResults
maxResults = min(opts.GLMSearchMaxResults, 10)
}
} else {
return nil, nil
@ -829,7 +1019,7 @@ func (t *WebSearchTool) Name() string {
}
func (t *WebSearchTool) Description() string {
return "Search the web for current information. Returns titles, URLs, and snippets from search results."
return "Search the web for current information. Supports query, count, and an optional temporal range filter. Returns titles, URLs, and snippets from search results."
}
func (t *WebSearchTool) Parameters() map[string]any {
@ -842,10 +1032,15 @@ func (t *WebSearchTool) Parameters() map[string]any {
},
"count": map[string]any{
"type": "integer",
"description": "Number of results (1-10)",
"description": "Number of results (default: 10, max: 10)",
"minimum": 1.0,
"maximum": 10.0,
},
"range": map[string]any{
"type": "string",
"description": "Optional time filter: d (day), w (week), m (month), y (year)",
"enum": []string{"d", "w", "m", "y"},
},
},
"required": []string{"query"},
}
@ -853,18 +1048,36 @@ func (t *WebSearchTool) Parameters() map[string]any {
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string)
if !ok {
if !ok || strings.TrimSpace(query) == "" {
return ErrorResult("query is required")
}
query = strings.TrimSpace(query)
count64, err := getInt64Arg(args, "count", int64(t.maxResults))
if err != nil {
return ErrorResult(err.Error())
}
count := t.maxResults
if c, ok := args["count"].(float64); ok {
if int(c) > 0 && int(c) <= 10 {
count = int(c)
if count64 > 0 && count64 <= 10 {
count = int(count64)
}
rangeCode, err := normalizeSearchRange("")
if err != nil {
return ErrorResult(err.Error())
}
if rawRange, exists := args["range"]; exists {
rangeStr, ok := rawRange.(string)
if !ok {
return ErrorResult("range must be a string")
}
rangeCode, err = normalizeSearchRange(rangeStr)
if err != nil {
return ErrorResult(err.Error())
}
}
result, err := t.provider.Search(ctx, query, count)
result, err := t.provider.Search(ctx, query, count, rangeCode)
if err != nil {
return ErrorResult(fmt.Sprintf("search failed: %v", err))
}
@ -1038,7 +1251,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes))
return ErrorResult(
fmt.Sprintf(
"failed to read response: size exceeded %d bytes limit",
t.fetchLimitBytes,
),
)
}
return ErrorResult(err.Error())
}
@ -1088,7 +1306,11 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
// If the charset is not utf-8, we might have to convert the bodyStr
// before passing it to the HTML/Markdown parser
if strings.ToLower(charset) != "utf-8" {
logger.WarnCF("tool", "Note: the content is not in UTF-8", map[string]any{"charset": charset})
logger.WarnCF(
"tool",
"Note: the content is not in UTF-8",
map[string]any{"charset": charset},
)
}
}
@ -1232,7 +1454,11 @@ func newSafeDialContext(
continue
}
attempted++
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
conn, err := dialer.DialContext(
ctx,
network,
net.JoinHostPort(ipAddr.IP.String(), port),
)
if err == nil {
return conn, nil
}
@ -1240,10 +1466,17 @@ func newSafeDialContext(
}
if attempted == 0 {
return nil, fmt.Errorf("all resolved addresses for %s are private, restricted, or not whitelisted", host)
return nil, fmt.Errorf(
"all resolved addresses for %s are private, restricted, or not whitelisted",
host,
)
}
if lastErr != nil {
return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr)
return nil, fmt.Errorf(
"failed connecting to public addresses for %s: %w",
host,
lastErr,
)
}
return nil, fmt.Errorf("failed connecting to public addresses for %s", host)
}

View file

@ -426,6 +426,96 @@ func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
}
}
func TestNormalizeSearchRange(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{name: "empty", input: "", want: ""},
{name: "day", input: "d", want: "d"},
{name: "week uppercase trimmed", input: " W ", want: "w"},
{name: "month", input: "m", want: "m"},
{name: "year", input: "y", want: "y"},
{name: "invalid", input: "q", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeSearchRange(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("normalizeSearchRange(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestSearchRangeMappings(t *testing.T) {
if got := mapBraveFreshness("d"); got != "pd" {
t.Fatalf("mapBraveFreshness(d) = %q, want pd", got)
}
if got := mapBraveFreshness("y"); got != "py" {
t.Fatalf("mapBraveFreshness(y) = %q, want py", got)
}
if got := mapTavilyTimeRange("w"); got != "week" {
t.Fatalf("mapTavilyTimeRange(w) = %q, want week", got)
}
if got := mapPerplexityRecencyFilter("m"); got != "month" {
t.Fatalf("mapPerplexityRecencyFilter(m) = %q, want month", got)
}
if got := mapDuckDuckGoDateFilter("y"); got != "t" {
t.Fatalf("mapDuckDuckGoDateFilter(y) = %q, want t", got)
}
if got := mapSearXNGTimeRange("d"); got != "day" {
t.Fatalf("mapSearXNGTimeRange(d) = %q, want day", got)
}
if got := mapGLMRecencyFilter("w"); got != "oneWeek" {
t.Fatalf("mapGLMRecencyFilter(w) = %q, want oneWeek", got)
}
if got := mapGLMRecencyFilter(""); got != "noLimit" {
t.Fatalf("mapGLMRecencyFilter(\"\") = %q, want noLimit", got)
}
if got := mapBaiduRecencyFilter("d"); got != "week" {
t.Fatalf("mapBaiduRecencyFilter(d) = %q, want week", got)
}
if got := mapBaiduRecencyFilter("m"); got != "month" {
t.Fatalf("mapBaiduRecencyFilter(m) = %q, want month", got)
}
}
func TestWebTool_WebSearch_InvalidRange(t *testing.T) {
tool, err := NewWebSearchTool(WebSearchToolOptions{
BraveEnabled: true,
BraveAPIKeys: []string{"test-key"},
BraveMaxResults: 5,
})
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"query": "test query",
"range": "invalid",
})
if !result.IsError {
t.Fatalf("expected invalid range to return error")
}
if !strings.Contains(result.ForLLM, "range must be one of: d, w, m, y") {
t.Fatalf("unexpected error message: %q", result.ForLLM)
}
}
// TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction
func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
withPrivateWebFetchHostsAllowed(t)
@ -1069,6 +1159,45 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
}
}
func TestWebTool_TavilySearch_RangeMapping(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode payload: %v", err)
}
if payload["time_range"] != "week" {
t.Fatalf("expected time_range=week, got %v", payload["time_range"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{"title": "Recent result", "url": "https://example.com/recent", "content": "snippet"},
},
})
}))
defer server.Close()
tool, err := NewWebSearchTool(WebSearchToolOptions{
TavilyEnabled: true,
TavilyAPIKeys: []string{"test-key"},
TavilyBaseURL: server.URL,
TavilyMaxResults: 5,
})
if err != nil {
t.Fatalf("NewWebSearchTool() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"query": "test query",
"range": "w",
})
if result.IsError {
t.Fatalf("expected success, got %s", result.ForLLM)
}
}
// TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response
// with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent,
// and that the retry response is returned when it succeeds.
@ -1297,6 +1426,39 @@ func TestWebTool_TavilySearch_Failover(t *testing.T) {
}
}
func TestWebTool_SearXNGSearch_RangeMapping(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("time_range"); got != "year" {
t.Fatalf("expected time_range=year, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"results": []map[string]any{
{"title": "Recent result", "url": "https://example.com/1", "content": "snippet"},
},
})
}))
defer server.Close()
tool, err := NewWebSearchTool(WebSearchToolOptions{
SearXNGEnabled: true,
SearXNGBaseURL: server.URL,
SearXNGMaxResults: 5,
})
if err != nil {
t.Fatalf("NewWebSearchTool() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"query": "test query",
"range": "y",
})
if result.IsError {
t.Fatalf("expected success, got %s", result.ForLLM)
}
}
func TestWebTool_GLMSearch_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
@ -1365,6 +1527,83 @@ func TestWebTool_GLMSearch_Success(t *testing.T) {
}
}
func TestWebTool_GLMSearch_RangeMapping(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode payload: %v", err)
}
if payload["search_recency_filter"] != "oneMonth" {
t.Fatalf("expected search_recency_filter=oneMonth, got %v", payload["search_recency_filter"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"search_result": []map[string]any{
{"title": "Recent GLM Result", "content": "snippet", "link": "https://example.com/glm-range"},
},
})
}))
defer server.Close()
tool, err := NewWebSearchTool(WebSearchToolOptions{
GLMSearchEnabled: true,
GLMSearchAPIKey: "test-glm-key",
GLMSearchBaseURL: server.URL,
GLMSearchEngine: "search_std",
})
if err != nil {
t.Fatalf("NewWebSearchTool() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"query": "test query",
"range": "m",
})
if result.IsError {
t.Fatalf("expected success, got %s", result.ForLLM)
}
}
func TestWebTool_BaiduSearch_RangeMapping(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode payload: %v", err)
}
if payload["search_recency_filter"] != "week" {
t.Fatalf("expected search_recency_filter=week for day fallback, got %v", payload["search_recency_filter"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{
"references": []map[string]any{
{"title": "Recent Baidu Result", "url": "https://example.com/baidu", "content": "snippet"},
},
})
}))
defer server.Close()
tool, err := NewWebSearchTool(WebSearchToolOptions{
BaiduSearchEnabled: true,
BaiduSearchAPIKey: "test-baidu-key",
BaiduSearchBaseURL: server.URL,
})
if err != nil {
t.Fatalf("NewWebSearchTool() error: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"query": "test query",
"range": "d",
})
if result.IsError {
t.Fatalf("expected success, got %s", result.ForLLM)
}
}
func TestWebTool_GLMSearch_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)

View file

@ -20,89 +20,72 @@ func TestDetectTranscriber(t *testing.T) {
},
{
name: "voice model name selects audio model transcriber",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "voice-gemini"},
ModelList: []*config.ModelConfig{
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"voice-gemini": {
APIKeys: []string{"sk-gemini-model"},
{
ModelName: "voice-gemini",
Model: "gemini/gemini-2.5-flash",
APIKeys: config.SimpleSecureStrings("sk-gemini-model"),
},
},
}),
},
wantName: "audio-model",
},
{
name: "groq via model list",
cfg: (&config.Config{
cfg: &config.Config{
ModelList: []*config.ModelConfig{
{ModelName: "openai", Model: "openai/gpt-4o"},
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"openai": {
APIKeys: []string{"sk-openai"},
},
"groq": {
APIKeys: []string{"sk-groq-model"},
{ModelName: "openai", Model: "openai/gpt-4o", APIKeys: config.SimpleSecureStrings("sk-openai")},
{
ModelName: "groq",
Model: "groq/llama-3.3-70b",
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
},
},
}),
},
wantName: "groq",
},
{
name: "voice model name selects non-gemini audio model transcriber",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "voice-openai-audio"},
ModelList: []*config.ModelConfig{
{ModelName: "voice-openai-audio", Model: "openai/gpt-4o-audio-preview"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"voice-openai-audio": {
APIKeys: []string{"sk-openai"},
{
ModelName: "voice-openai-audio",
Model: "openai/gpt-4o-audio-preview",
APIKeys: config.SimpleSecureStrings("sk-openai"),
},
},
}),
},
wantName: "audio-model",
},
{
name: "voice model name selects azure audio model transcriber",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "voice-azure-audio"},
ModelList: []*config.ModelConfig{
{
ModelName: "voice-azure-audio",
Model: "azure/my-audio-deployment",
APIBase: "https://example.openai.azure.com",
Model: "azure/my-audio-deployment", APIKeys: config.SimpleSecureStrings("sk-azure"),
APIBase: "https://example.openai.azure.com",
},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"voice-azure-audio": {
APIKeys: []string{"sk-azure"},
},
},
}),
},
wantName: "audio-model",
},
{
name: "voice model name with non openai compatible protocol does not select audio model transcriber",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "voice-anthropic"},
ModelList: []*config.ModelConfig{
{ModelName: "voice-anthropic", Model: "anthropic/claude-sonnet-4.6"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"voice-anthropic": {
APIKeys: []string{"sk-anthropic"},
{
ModelName: "voice-anthropic",
Model: "anthropic/claude-sonnet-4.6",
APIKeys: config.SimpleSecureStrings("sk-anthropic"),
},
},
}),
},
wantNil: true,
},
{
@ -116,33 +99,29 @@ func TestDetectTranscriber(t *testing.T) {
},
{
name: "provider key takes priority over model list",
cfg: (&config.Config{
cfg: &config.Config{
ModelList: []*config.ModelConfig{
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"groq": {
APIKeys: []string{"sk-groq-model"},
{
ModelName: "groq",
Model: "groq/llama-3.3-70b",
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
},
},
}),
},
wantName: "groq",
},
{
name: "missing voice model name config returns nil",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "missing"},
ModelList: []*config.ModelConfig{
{ModelName: "other", Model: "gemini/gemini-2.5-flash"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"other": {
APIKeys: []string{"sk-other-model"},
{
ModelName: "other",
Model: "gemini/gemini-2.5-flash",
APIKeys: config.SimpleSecureStrings("sk-other-model"),
},
},
}),
},
wantNil: true,
},
{
@ -154,37 +133,33 @@ func TestDetectTranscriber(t *testing.T) {
},
{
name: "elevenlabs takes priority over groq model list",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{ElevenLabsAPIKey: "sk_elevenlabs_test"},
ModelList: []*config.ModelConfig{
{ModelName: "groq", Model: "groq/llama-3.3-70b"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"groq": {
APIKeys: []string{"sk-groq-direct"},
{
ModelName: "groq",
Model: "groq/llama-3.3-70b",
APIKeys: config.SimpleSecureStrings("sk-groq-model"),
},
},
}),
},
wantName: "elevenlabs",
},
{
name: "voice model name takes priority over elevenlabs",
cfg: (&config.Config{
cfg: &config.Config{
Voice: config.VoiceConfig{
ModelName: "voice-gemini",
ElevenLabsAPIKey: "sk_elevenlabs_test",
},
ModelList: []*config.ModelConfig{
{ModelName: "voice-gemini", Model: "gemini/gemini-2.5-flash"},
},
}).WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"voice-gemini": {
APIKeys: []string{"sk-gemini-model"},
{
ModelName: "voice-gemini",
Model: "gemini/gemini-2.5-flash",
APIKeys: config.SimpleSecureStrings("sk-gemini-model"),
},
},
}),
},
wantName: "audio-model",
},
}

View file

@ -58,12 +58,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Load existing config and copy security credentials before validation,
// so that security-managed fields (e.g. pico token) are available.
oldCfg, err := config.LoadConfig(h.configPath)
err = cfg.SecurityCopyFrom(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
cfg.SecurityCopyFrom(oldCfg)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@ -149,15 +148,14 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
}
var newCfg config.Config
if err := json.Unmarshal(merged, &newCfg); err != nil {
if err = json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
// Restore security fields (tokens/keys) from the loaded config before validation,
// because private fields are lost during JSON round-trip.
newCfg.SecurityCopyFrom(cfg)
if err := newCfg.ApplySecurity(); err != nil {
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
@ -261,17 +259,17 @@ func validateConfig(cfg *config.Config) []string {
}
// Pico channel: token required when enabled
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token() == "" {
if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token.String() == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
// Telegram: token required when enabled
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token() == "" {
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token.String() == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
// Discord: token required when enabled
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token() == "" {
if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token.String() == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
@ -279,7 +277,7 @@ func validateConfig(cfg *config.Config) []string {
if cfg.Channels.WeCom.BotID == "" {
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
}
if cfg.Channels.WeCom.Secret() == "" {
if cfg.Channels.WeCom.Secret.String() == "" {
errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
}
}

View file

@ -163,17 +163,11 @@ func setupPicoEnabledEnv(t *testing.T) (string, func()) {
cfg.ModelList = []*config.ModelConfig{{
ModelName: "custom-default",
Model: "openai/gpt-4o",
APIKeys: config.SimpleSecureStrings("sk-default"),
}}
cfg.Agents.Defaults.ModelName = "custom-default"
cfg.Channels.Pico.Enabled = true
cfg.WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"custom-default": {APIKeys: []string{"sk-default"}},
},
Channels: &config.ChannelsSecurity{
Pico: &config.PicoSecurity{Token: "test-pico-token"},
},
})
cfg.Channels.Pico.Token = *config.NewSecureString("test-pico-token")
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {

View file

@ -80,6 +80,7 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
ModelName: "vllm-remote",
Model: "vllm/custom-model",
APIBase: "https://models.example.com/v1",
APIKeys: config.SimpleSecureStrings("remote-key"),
},
{
ModelName: "copilot-gpt-5.4",
@ -88,11 +89,6 @@ func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *tes
AuthMethod: "oauth",
},
}
cfg.WithSecurity(&config.SecurityConfig{ModelList: map[string]config.ModelSecurityEntry{
"vllm-remote": {
APIKeys: []string{"remote-key"},
},
}})
cfg.Agents.Defaults.ModelName = "openai-oauth"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)

View file

@ -232,15 +232,9 @@ func setupOAuthTestEnv(t *testing.T) (string, func()) {
cfg.ModelList = []*config.ModelConfig{{
ModelName: "custom-default",
Model: "openai/gpt-4o",
APIKeys: config.SimpleSecureStrings("sk-default"),
}}
cfg.Agents.Defaults.ModelName = "custom-default"
cfg.WithSecurity(&config.SecurityConfig{
ModelList: map[string]config.ModelSecurityEntry{
"custom-default": {
APIKeys: []string{"sk-default"},
},
},
})
configPath := filepath.Join(tmp, "config.json")
if err := config.SaveConfig(configPath, cfg); err != nil {

View file

@ -57,7 +57,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token(),
"token": cfg.Channels.Pico.Token.String(),
"ws_url": wsURL,
"enabled": cfg.Channels.Pico.Enabled,
})
@ -110,7 +110,7 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) {
changed = true
}
if cfg.Channels.Pico.Token() == "" {
if cfg.Channels.Pico.Token.String() == "" {
cfg.Channels.Pico.SetToken(generateSecureToken())
changed = true
}
@ -150,7 +150,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"token": cfg.Channels.Pico.Token(),
"token": cfg.Channels.Pico.Token.String(),
"ws_url": wsURL,
"enabled": true,
"changed": changed,

View file

@ -34,7 +34,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) {
if !cfg.Channels.Pico.Enabled {
t.Error("expected Pico to be enabled after setup")
}
if cfg.Channels.Pico.Token() == "" {
if cfg.Channels.Pico.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
}
@ -144,8 +144,8 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) {
t.Fatalf("LoadConfig() error = %v", err)
}
if cfg.Channels.Pico.Token() != "user-custom-token" {
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token(), "user-custom-token")
if cfg.Channels.Pico.Token.String() != "user-custom-token" {
t.Errorf("token = %q, want %q", cfg.Channels.Pico.Token.String(), "user-custom-token")
}
if !cfg.Channels.Pico.AllowTokenQuery {
t.Error("user's allow_token_query=true must be preserved")
@ -185,7 +185,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) {
if !cfg.Channels.Pico.Enabled {
t.Error("expected Pico to be enabled after setup")
}
if cfg.Channels.Pico.Token() == "" {
if cfg.Channels.Pico.Token.String() == "" {
t.Error("expected a non-empty token after setup")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(configPath), config.SecurityConfigFile)); err != nil {
@ -215,7 +215,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) {
if !cfg.Channels.Pico.Enabled {
t.Error("expected Pico to be enabled after launcher startup setup")
}
if cfg.Channels.Pico.Token() == "" {
if cfg.Channels.Pico.Token.String() == "" {
t.Error("expected a non-empty token after launcher startup setup")
}
}
@ -232,7 +232,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg1, _ := config.LoadConfig(configPath)
token1 := cfg1.Channels.Pico.Token()
token1 := cfg1.Channels.Pico.Token.String()
// Second call should be a no-op
changed, err := h.EnsurePicoChannel(origin)
@ -244,7 +244,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) {
}
cfg2, _ := config.LoadConfig(configPath)
if cfg2.Channels.Pico.Token() != token1 {
if cfg2.Channels.Pico.Token.String() != token1 {
t.Error("token should not change on subsequent calls")
}
}

View file

@ -44,7 +44,7 @@ func TestSaveWeixinBindingReturnsSuccessWhenRestartFails(t *testing.T) {
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := savedCfg.Channels.Weixin.Token(); got != "bot-token" {
if got := savedCfg.Channels.Weixin.Token.String(); got != "bot-token" {
t.Fatalf("Weixin.Token() = %q, want %q", got, "bot-token")
}
if got := savedCfg.Channels.Weixin.AccountID; got != "bot-account" {

View file

@ -5,7 +5,7 @@ interface UserMessageProps {
export function UserMessage({ content }: UserMessageProps) {
return (
<div className="flex w-full flex-col items-end gap-1.5">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm">
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-violet-500 px-5 py-3 text-[15px] leading-relaxed whitespace-pre-wrap text-white shadow-sm break-words">
{content}
</div>
</div>