.`
-
-Examples:
-- `ref:skills.github.token`
-- `ref:skills.clawhub.auth_token`
-
-## Backward Compatibility
-
-The refactoring maintains full backward compatibility:
-
-1. **Direct values**: You can still use direct values in `config.json` (not recommended for production)
-2. **Mixed usage**: You can mix `ref:` references and direct values
-3. **Optional security file**: If `.security.yml` doesn't exist, all references will fail (but direct values still work)
-
-## Configuration Precedence
-
-When both `config.json` and `.security.yml` contain security configurations, PicoClaw uses the following precedence rules:
-
-### Priority Order (Highest to Lowest)
-
-1. **Security settings in `config.json`** (highest priority)
- - Direct values in `config.json`
- - These settings override any conflicting values in `.security.yml`
-
-2. **Security settings in `.security.yml`**
- - Used when no conflicting setting exists in `config.json`
- - Provides default/fallback security values
-
-### Practical Example
-
-**Scenario**: You have API keys defined in both files.
-
-**.security.yml:**
-```yaml
-model_list:
- gpt-4o:
- api_keys:
- - "sk-default-key-from-security-yml"
-```
-
-**config.json:**
-```json
-{
- "model_list": [
- {
- "model_name": "gpt-4o",
- "api_key": "sk-custom-key-from-config-json"
- }
- ]
-}
-```
-
-**Result**: The API key `"sk-custom-key-from-config-json"` from `config.json` takes precedence.
-
-### Use Cases
-
-This precedence system enables several useful patterns:
-
-1. **Environment-specific overrides**: Keep default keys in `.security.yml`, override per-environment keys in `config.json`
-2. **Temporary testing**: Quickly test a new API key in `config.json` without modifying `.security.yml`
-3. **Team sharing**: Share common keys via `.security.yml` while allowing individual developers to override in their local `config.json`
-
-### Migration Behavior
-
-When migrating from config v0 to v1:
-- Security values extracted from legacy `config.json` take precedence
-- Existing `.security.yml` values serve as fallback
-- No data loss: all values are preserved and merged appropriately
-
-### API Key Formats in .security.yml
-
-**Models (gpt-5.4, claude-sonnet-4.6, etc.):**
-- Must use `api_keys` (array) format
-- Both single and multiple keys use array format
-
-**Web Tools (Brave, Tavily, Perplexity):**
-- Must use `api_keys` (array) format
-- Both single and multiple keys use array format
-
-**Web Tools (GLMSearch):**
-- Must use `api_key` (single string) format
-- Does NOT support array format
-
-**Channels (Telegram, Discord, etc.):**
-- Use single field names (e.g., `token`, `app_secret`)
-- Each channel uses its specific field names
-
-### Single Key (Models)
-
-Use array format with one element:
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-your-key"
-```
-
-In `config.json`:
-```json
-{
- "api_key": "ref:model_list.gpt-5.4.api_key"
-}
-```
-
-### Single Key (GLMSearch)
-
-Use single string format:
-```yaml
-web:
- glm_search:
- api_key: "your-glm-key"
-```
-
-In `config.json`:
-```json
-{
- "api_key": "ref:web.glm_search.api_key"
-}
-```
-
-## Migration Guide
-
-### Step 1: Create .security.yml
-
-Copy the example template:
-```bash
-cp security.example.yml ~/.picoclaw/.security.yml
-```
-
-### Step 2: Fill in your actual values
-
-Edit `~/.picoclaw/.security.yml` and replace placeholder values with your actual API keys and tokens.
-
-### Step 3: Update config.json
-
-Replace sensitive values in `~/.picoclaw/config.json` with `ref:` references:
-
-**Before:**
-```json
-{
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_key": "sk-your-actual-api-key-here"
- }
- ]
-}
-```
-
-**After:**
-```json
-{
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_key": "ref:model_list.gpt-5.4.api_key"
- }
- ]
-}
-```
-
-### Step 4: Verify
-
-Restart PicoClaw and verify it loads correctly:
-```bash
-picoclaw --version
-```
-
-## Security Best Practices
-
-1. **Never commit `.security.yml`** to version control
-2. **Set file permissions**: `chmod 600 ~/.picoclaw/.security.yml`
-3. **Use different keys** for different environments (dev, staging, production)
-4. **Rotate keys regularly** and update `.security.yml`
-5. **Backup securely**: Encrypt backups containing `.security.yml`
-
-## API
-
-### LoadSecurityConfig
-
-```go
-func LoadSecurityConfig(securityPath string) (*SecurityConfig, error)
-```
-
-Loads the security configuration from `.security.yml`. Returns an empty `SecurityConfig` if the file doesn't exist.
-
-### SaveSecurityConfig
-
-```go
-func SaveSecurityConfig(securityPath string, sec *SecurityConfig) error
-```
-
-Saves the security configuration to `.security.yml` with `0o600` permissions.
-
-### ResolveReference
-
-```go
-func (sec *SecurityConfig) ResolveReference(ref string) (string, error)
-```
-
-Resolves a reference string (e.g., `"ref:model_list.test.api_key"`) and returns the actual value.
-
-### SecurityPath
-
-```go
-func SecurityPath(configPath string) string
-```
-
-Returns the path to `.security.yml` relative to the config file.
-
-## Example: Complete Configuration
-
-### config.json
-```json
-{
- "version": 1,
- "agents": {
- "defaults": {
- "workspace": "~/picoclaw-workspace",
- "model_name": "gpt-5.4"
- }
- },
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_base": "https://api.openai.com/v1",
- "api_key": "ref:model_list.gpt-5.4.api_key"
- },
- {
- "model_name": "claude-sonnet-4.6",
- "model": "anthropic/claude-sonnet-4.6",
- "api_base": "https://api.anthropic.com/v1",
- "api_key": "ref:model_list.claude-sonnet-4.6.api_key"
- }
- ],
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "ref:channels.telegram.token"
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": true,
- "api_key": "ref:web.brave.api_key"
- }
- }
- }
-}
-```
-
-### .security.yml
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-proj-actual-openai-key-1"
- - "sk-proj-actual-openai-key-2"
- claude-sonnet-4.6:
- api_keys:
- - "sk-ant-actual-anthropic-key" # Single key in array format
-
-channels:
- telegram:
- token: "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
-
-web:
- brave:
- api_keys:
- - "BSAactualbravekey-1"
- - "BSAactualbravekey-2"
- tavily:
- api_keys:
- - "tvly-your-tavily-key" # Single key in array format
- glm_search:
- api_key: "your-glm-key" # GLMSearch uses single key format
-```
-
-## Testing
-
-The refactoring includes comprehensive tests:
-
-```bash
-go test ./pkg/config -run TestSecurityConfig
-```
-
-## Troubleshooting
-
-### Error: "model security entry not found"
-
-- Ensure the model name in your reference matches exactly in `.security.yml`
-- Check that the `model_list` section exists in `.security.yml`
-- For models with indexed names (e.g., "gpt-5.4:0"), ensure the exact name is used or check the base name without index
-
-### Error: "failed to load security config"
-
-- Verify `.security.yml` exists in the same directory as `config.json`
-- Check the YAML syntax is valid (use a YAML validator)
-- Ensure file permissions allow reading
-
-### Error: "unknown reference path"
-
-- Verify the reference format is correct
-- Check the path structure matches the examples above
-- Ensure all required sections exist in `.security.yml`
-
-## Advanced Features
-
-### Multiple API Keys (Load Balancing & Failover)
-
-Both models and web tools support multiple API keys for improved reliability:
-
-**Benefits:**
-- **Load balancing**: Requests are distributed across multiple keys
-- **Failover**: Automatic switching to another key if one fails
-- **Rate limit management**: Distribute usage across multiple keys
-- **High availability**: Reduce downtime during API provider issues
-
-#### Example: Model with Multiple Keys
-
-**.security.yml:**
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-proj-key-1"
- - "sk-proj-key-2"
- - "sk-proj-key-3"
-```
-
-**config.json:**
-```json
-{
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_key": "ref:model_list.gpt-5.4.api_key"
- }
- ]
-}
-```
-
-#### Example: Web Tool with Multiple Keys
-
-**.security.yml:**
-```yaml
-web:
- brave:
- api_keys:
- - "BSA-key-1"
- - "BSA-key-2"
- tavily:
- api_keys:
- - "tvly-your-key" # Single key in array format
- glm_search:
- api_key: "your-glm-key" # GLMSearch uses single key format
-```
-
-**config.json:**
-```json
-{
- "tools": {
- "web": {
- "brave": {
- "enabled": true,
- "api_key": "ref:web.brave.api_key"
- },
- "tavily": {
- "enabled": true,
- "api_key": "ref:web.tavily.api_key"
- }
- }
- }
-}
-```
-
-#### Supported Formats
-
-**Models - Single key:**
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-your-key" # Array with one element
-```
-
-**Models - Multiple keys:**
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-your-key-1"
- - "sk-your-key-2"
- - "sk-your-key-3"
-```
-
-**Web Tools (Brave/Tavily/Perplexity) - Single key:**
-```yaml
-web:
- brave:
- api_keys:
- - "BSA-your-key" # Array with one element
-```
-
-**Web Tools (Brave/Tavily/Perplexity) - Multiple keys:**
-```yaml
-web:
- brave:
- api_keys:
- - "BSA-key-1"
- - "BSA-key-2"
-```
-
-**Web Tool (GLMSearch) - Single key only:**
-```yaml
-web:
- glm_search:
- api_key: "your-glm-key" # Single string (NOT array)
-```
-
-All formats work identically in `config.json` - you always use the same reference format:
-```json
-{
- "api_key": "ref:model_list.gpt-5.4.api_key"
-}
-```
-
-### Model Indexing for Load Balancing
-
-When you have multiple models with the same base name but different API keys, you can use indexed names:
-
-**.security.yml:**
-```yaml
-model_list:
- gpt-5.4:
- api_keys:
- - "sk-proj-key-1"
- - "sk-proj-key-2"
-```
-
-The system will automatically expand this into multiple model entries with fallback support.
-
-### Environment Variables
-
-You can override any security value using environment variables:
-
-**For models:**
-```bash
-export PICOCLAW_MODEL_LIST_GPT-5.4_API_KEY="sk-from-env"
-```
-
-**For channels:**
-```bash
-export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
-```
-
-**For web tools:**
-```bash
-export PICOCLAW_WEB_BRAVE_API_KEY="key-from-env"
-```
-
-Environment variables follow this pattern: `PICOCLAW____` with dots replaced by underscores and converted to uppercase.
-
-### Multiple API Keys Not Working
-
-- Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch)
-- Check that the array format is correct in YAML (proper indentation)
-- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
-- GLMSearch MUST use `api_key` (single string format)
-- The reference in `config.json` is the same regardless of single or multiple keys
-
-### Load Balancing/Failover Issues
-
-- Verify all API keys in the `api_keys` array are valid
-- Check that all keys have the same rate limits and permissions
-- Monitor logs to see which keys are being used and failing
diff --git a/pkg/config/config.go b/pkg/config/config.go
index b5205a24e..c61219d9b 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -321,10 +321,7 @@ type AgentDefaults struct {
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
}
-const (
- DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
- DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly."
-)
+const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB
func (d *AgentDefaults) GetMaxMediaSize() int {
if d.MaxMediaSize > 0 {
@@ -364,9 +361,7 @@ type ChannelsConfig struct {
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
- WeCom WeComConfig `json:"wecom"`
- WeComApp WeComAppConfig `json:"wecom_app"`
- WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
+ WeCom WeComConfig `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
Weixin WeixinConfig `json:"weixin"`
Pico PicoConfig `json:"pico"`
PicoClient PicoClientConfig `json:"pico_client"`
@@ -386,7 +381,7 @@ type TypingConfig struct {
// PlaceholderConfig controls placeholder message behavior (Phase 10).
type PlaceholderConfig struct {
- Enabled bool `json:"enabled,omitempty"`
+ Enabled bool `json:"enabled"`
Text string `json:"text,omitempty"`
}
@@ -591,18 +586,20 @@ func (c *SlackConfig) SetAppToken(token string) {
}
type MatrixConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
- Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
- UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
+ Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
+ UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
accessToken string
- DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
- JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
- MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
+ DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
+ JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
+ MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
secDirty bool
+ CryptoDatabasePath string `json:"crypto_database_path,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_DATABASE_PATH"`
+ CryptoPassphrase string `json:"crypto_passphrase,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_CRYPTO_PASSPHRASE"`
}
// AccessToken returns the Matrix access token
@@ -678,136 +675,28 @@ func (c *OneBotConfig) SetAccessToken(token string) {
c.secDirty = true
}
+type WeComGroupConfig struct {
+ AllowFrom FlexibleStringSlice `json:"allow_from,omitempty"`
+}
+
type WeComConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- token string
- encodingAESKey string
- WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
- secDirty bool
+ Enabled bool `json:"enabled" env:"ENABLED"`
+ BotID string `json:"bot_id" env:"BOT_ID"`
+ secret string
+ WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"`
+ SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
+ secDirty bool
}
-// Token returns the WeCom token
-func (c *WeComConfig) Token() string {
- return c.token
-}
-
-// SetToken sets the WeCom token
-func (c *WeComConfig) SetToken(token string) {
- c.token = token
- c.secDirty = true
-}
-
-// EncodingAESKey returns the WeCom encoding AES key
-func (c *WeComConfig) EncodingAESKey() string {
- return c.encodingAESKey
-}
-
-// SetEncodingAESKey sets the WeCom encoding AES key
-func (c *WeComConfig) SetEncodingAESKey(key string) {
- c.encodingAESKey = key
- c.secDirty = true
-}
-
-type WeComAppConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- corpSecret string
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- token string
- encodingAESKey string
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
- secDirty bool
-}
-
-// CorpSecret returns the corporate secret for WeCom app
-func (c *WeComAppConfig) CorpSecret() string {
- return c.corpSecret
-}
-
-// SetCorpSecret sets the corporate secret for WeCom app
-func (c *WeComAppConfig) SetCorpSecret(secret string) {
- c.corpSecret = secret
- c.secDirty = true
-}
-
-// Token returns the webhook token for WeCom app
-func (c *WeComAppConfig) Token() string {
- return c.token
-}
-
-// SetToken sets the webhook token for WeCom app
-func (c *WeComAppConfig) SetToken(token string) {
- c.token = token
- c.secDirty = true
-}
-
-// EncodingAESKey returns the encoding AES key for WeCom app
-func (c *WeComAppConfig) EncodingAESKey() string {
- return c.encodingAESKey
-}
-
-// SetEncodingAESKey sets the encoding AES key for WeCom app
-func (c *WeComAppConfig) SetEncodingAESKey(key string) {
- c.encodingAESKey = key
- c.secDirty = true
-}
-
-type WeComAIBotConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
- BotID string `json:"bot_id,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_BOT_ID"`
- secret string
- token string
- encodingAESKey string
- WebhookPath string `json:"webhook_path,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
- MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
- WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
- ProcessingMessage string `json:"processing_message,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_PROCESSING_MESSAGE"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
- secDirty bool
-}
-
-// Token returns the webhook token for WeCom AI bot
-func (c *WeComAIBotConfig) Token() string {
- return c.token
-}
-
-// EncodingAESKey returns the encoding AES key for WeCom AI bot
-func (c *WeComAIBotConfig) EncodingAESKey() string {
- return c.encodingAESKey
-}
-
-// SetToken sets the token for WeCom AI bot
-func (c *WeComAIBotConfig) SetToken(token string) {
- c.token = token
- c.secDirty = true
-}
-
-// SetEncodingAESKey sets the encoding AES key for WeCom AI bot
-func (c *WeComAIBotConfig) SetEncodingAESKey(key string) {
- c.encodingAESKey = key
- c.secDirty = true
-}
-
-func (c *WeComAIBotConfig) Secret() string {
+// Secret returns the WeCom bot secret.
+func (c *WeComConfig) Secret() string {
return c.secret
}
-func (c *WeComAIBotConfig) SetSecret(secret string) {
+// SetSecret sets the WeCom bot secret.
+func (c *WeComConfig) SetSecret(secret string) {
c.secret = secret
c.secDirty = true
}
@@ -968,6 +857,10 @@ type ModelConfig struct {
secModelName string
apiKeys []string
secDirty bool
+
+ // isVirtual marks this model as a virtual model generated from multi-key expansion.
+ // Virtual models should not be persisted to config files.
+ isVirtual bool
}
// APIKey returns the first API key from apiKeys
@@ -978,6 +871,11 @@ func (c *ModelConfig) APIKey() string {
return ""
}
+// IsVirtual returns true if this model was generated from multi-key expansion.
+func (c *ModelConfig) IsVirtual() bool {
+ return c.isVirtual
+}
+
// Validate checks if the ModelConfig has all required fields.
func (c *ModelConfig) Validate() error {
if c.ModelName == "" {
@@ -1635,39 +1533,10 @@ func applySecurityConfig(cfg *Config, sec *SecurityConfig) error {
cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken
}
- // Handle WeCom token and encoding key
+ // Handle WeCom bot secret
if sec.Channels.WeCom != nil {
- if sec.Channels.WeCom.Token != "" {
- cfg.Channels.WeCom.token = sec.Channels.WeCom.Token
- }
- if sec.Channels.WeCom.EncodingAESKey != "" {
- cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey
- }
- }
-
- // Handle WeCom App credentials
- if sec.Channels.WeComApp != nil {
- if sec.Channels.WeComApp.CorpSecret != "" {
- cfg.Channels.WeComApp.corpSecret = sec.Channels.WeComApp.CorpSecret
- }
- if sec.Channels.WeComApp.Token != "" {
- cfg.Channels.WeComApp.token = sec.Channels.WeComApp.Token
- }
- if sec.Channels.WeComApp.EncodingAESKey != "" {
- cfg.Channels.WeComApp.encodingAESKey = sec.Channels.WeComApp.EncodingAESKey
- }
- }
-
- // Handle WeCom AI Bot credentials
- if sec.Channels.WeComAIBot != nil {
- if sec.Channels.WeComAIBot.Token != "" {
- cfg.Channels.WeComAIBot.token = sec.Channels.WeComAIBot.Token
- }
- if sec.Channels.WeComAIBot.EncodingAESKey != "" {
- cfg.Channels.WeComAIBot.encodingAESKey = sec.Channels.WeComAIBot.EncodingAESKey
- }
- if sec.Channels.WeComAIBot.Secret != "" {
- cfg.Channels.WeComAIBot.secret = sec.Channels.WeComAIBot.Secret
+ if sec.Channels.WeCom.Secret != "" {
+ cfg.Channels.WeCom.secret = sec.Channels.WeCom.Secret
}
}
@@ -1913,27 +1782,10 @@ func SaveConfig(path string, cfg *Config) error {
}
if cfg.Channels.WeCom.secDirty {
cfg.security.Channels.WeCom = &WeComSecurity{
- Token: cfg.Channels.WeCom.Token(),
- EncodingAESKey: cfg.Channels.WeCom.EncodingAESKey(),
+ Secret: cfg.Channels.WeCom.Secret(),
}
cfg.Channels.WeCom.secDirty = false
}
- if cfg.Channels.WeComApp.secDirty {
- cfg.security.Channels.WeComApp = &WeComAppSecurity{
- CorpSecret: cfg.Channels.WeComApp.CorpSecret(),
- Token: cfg.Channels.WeComApp.Token(),
- EncodingAESKey: cfg.Channels.WeComApp.EncodingAESKey(),
- }
- cfg.Channels.WeComApp.secDirty = false
- }
- if cfg.Channels.WeComAIBot.secDirty {
- cfg.security.Channels.WeComAIBot = &WeComAIBotSecurity{
- Token: cfg.Channels.WeComAIBot.Token(),
- EncodingAESKey: cfg.Channels.WeComAIBot.EncodingAESKey(),
- Secret: cfg.Channels.WeComAIBot.Secret(),
- }
- cfg.Channels.WeComAIBot.secDirty = false
- }
if cfg.Tools.Web.Brave.secDirty {
cfg.security.Web.Brave = &BraveSecurity{
APIKeys: cfg.Tools.Web.Brave.APIKeys(),
@@ -1991,7 +1843,20 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
+ // Filter out virtual models before serializing to config file
+ nonVirtualModels := make([]*ModelConfig, 0, len(cfg.ModelList))
+ for _, m := range cfg.ModelList {
+ if !m.isVirtual {
+ nonVirtualModels = append(nonVirtualModels, m)
+ }
+ }
+ // Temporarily replace ModelList with filtered version for serialization
+ originalModelList := cfg.ModelList
+ cfg.ModelList = nonVirtualModels
+
data, err := json.MarshalIndent(cfg, "", " ")
+ // Restore original ModelList after serialization
+ cfg.ModelList = originalModelList
if err != nil {
return err
}
@@ -2209,6 +2074,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody,
+ isVirtual: true,
}
expanded = append(expanded, additionalEntry)
fallbackNames = append(fallbackNames, expandedName)
diff --git a/pkg/config/config_old.go b/pkg/config/config_old.go
index 01909f5a9..44c9435d1 100644
--- a/pkg/config/config_old.go
+++ b/pkg/config/config_old.go
@@ -85,23 +85,21 @@ type toolsConfigV0 struct {
}
type channelsConfigV0 struct {
- WhatsApp WhatsAppConfig `json:"whatsapp"`
- Telegram telegramConfigV0 `json:"telegram"`
- Feishu feishuConfigV0 `json:"feishu"`
- Discord discordConfigV0 `json:"discord"`
- MaixCam maixcamConfigV0 `json:"maixcam"`
- Weixin weixinConfigV0 `json:"weixin"`
- QQ qqConfigV0 `json:"qq"`
- DingTalk dingtalkConfigV0 `json:"dingtalk"`
- Slack slackConfigV0 `json:"slack"`
- Matrix matrixConfigV0 `json:"matrix"`
- LINE lineConfigV0 `json:"line"`
- OneBot onebotConfigV0 `json:"onebot"`
- WeCom wecomConfigV0 `json:"wecom"`
- WeComApp wecomappConfigV0 `json:"wecom_app"`
- WeComAIBot wecomaibotConfigV0 `json:"wecom_aibot"`
- Pico picoConfigV0 `json:"pico"`
- IRC ircConfigV0 `json:"irc"`
+ WhatsApp WhatsAppConfig `json:"whatsapp"`
+ Telegram telegramConfigV0 `json:"telegram"`
+ Feishu feishuConfigV0 `json:"feishu"`
+ Discord discordConfigV0 `json:"discord"`
+ MaixCam maixcamConfigV0 `json:"maixcam"`
+ Weixin weixinConfigV0 `json:"weixin"`
+ QQ qqConfigV0 `json:"qq"`
+ DingTalk dingtalkConfigV0 `json:"dingtalk"`
+ Slack slackConfigV0 `json:"slack"`
+ Matrix matrixConfigV0 `json:"matrix"`
+ LINE lineConfigV0 `json:"line"`
+ OneBot onebotConfigV0 `json:"onebot"`
+ WeCom wecomConfigV0 `json:"wecom" envPrefix:"PICOCLAW_CHANNELS_WECOM_"`
+ Pico picoConfigV0 `json:"pico"`
+ IRC ircConfigV0 `json:"irc"`
}
func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity) {
@@ -117,45 +115,39 @@ func (v *channelsConfigV0) ToChannelsConfig() (ChannelsConfig, ChannelsSecurity)
line, lineSecurity := v.LINE.ToLINEConfig()
onebot, onebotSecurity := v.OneBot.ToOneBotConfig()
wecom, wecomSecurity := v.WeCom.ToWeComConfig()
- wecomapp, wecomappSecurity := v.WeComApp.ToWeComAppConfig()
- wecomaibot, wecomaibotSecurity := v.WeComAIBot.ToWeComAIBotConfig()
pico, picoSecurity := v.Pico.ToPicoConfig()
irc, ircSecurity := 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,
- WeComApp: wecomapp,
- WeComAIBot: wecomaibot,
- Pico: pico,
- IRC: irc,
+ 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,
- WeComApp: wecomappSecurity,
- WeComAIBot: wecomaibotSecurity,
- Pico: picoSecurity,
- IRC: ircSecurity,
+ Telegram: telegramSecurity,
+ Feishu: feishuSecurity,
+ Discord: discordSecurity,
+ QQ: qqSecurity,
+ Weixin: weixinSecurity,
+ DingTalk: dingtalkSecurity,
+ Slack: slackSecurity,
+ Matrix: matrixSecurity,
+ LINE: lineSecurity,
+ OneBot: onebotSecurity,
+ WeCom: wecomSecurity,
+ Pico: picoSecurity,
+ IRC: ircSecurity,
}
}
@@ -473,39 +465,32 @@ func (v *onebotConfigV0) ToOneBotConfig() (OneBotConfig, *OneBotSecurity) {
}
type wecomConfigV0 struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
- WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
+ Enabled bool `json:"enabled" env:"ENABLED"`
+ BotID string `json:"bot_id" env:"BOT_ID"`
+ Secret string `json:"secret" env:"SECRET"`
+ WebSocketURL string `json:"websocket_url,omitempty" env:"WEBSOCKET_URL"`
+ SendThinkingMessage bool `json:"send_thinking_message" env:"SEND_THINKING_MESSAGE"`
+ DMPolicy string `json:"dm_policy,omitempty" env:"DM_POLICY"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"ALLOW_FROM"`
+ GroupPolicy string `json:"group_policy,omitempty" env:"GROUP_POLICY"`
+ GroupAllowFrom FlexibleStringSlice `json:"group_allow_from,omitempty" env:"GROUP_ALLOW_FROM"`
+ Groups map[string]WeComGroupConfig `json:"groups,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"REASONING_CHANNEL_ID"`
}
func (v *wecomConfigV0) ToWeComConfig() (WeComConfig, *WeComSecurity) {
var sec *WeComSecurity
- if v.Token != "" || v.EncodingAESKey != "" {
- sec = &WeComSecurity{
- Token: v.Token,
- EncodingAESKey: v.EncodingAESKey,
- }
+ if v.Secret != "" {
+ sec = &WeComSecurity{Secret: v.Secret}
}
return WeComConfig{
- Enabled: v.Enabled,
- token: v.Token,
- encodingAESKey: v.EncodingAESKey,
- WebhookURL: v.WebhookURL,
- WebhookHost: v.WebhookHost,
- WebhookPort: v.WebhookPort,
- WebhookPath: v.WebhookPath,
- AllowFrom: v.AllowFrom,
- ReplyTimeout: v.ReplyTimeout,
- GroupTrigger: v.GroupTrigger,
- ReasoningChannelID: v.ReasoningChannelID,
+ Enabled: v.Enabled,
+ BotID: v.BotID,
+ secret: v.Secret,
+ WebSocketURL: v.WebSocketURL,
+ SendThinkingMessage: v.SendThinkingMessage,
+ AllowFrom: v.AllowFrom,
+ ReasoningChannelID: v.ReasoningChannelID,
}, sec
}
@@ -537,81 +522,6 @@ func (v *weixinConfigV0) ToWeiXinConfig() (WeixinConfig, *WeixinSecurity) {
}, sec
}
-type wecomappConfigV0 struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
- CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
- CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
- WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
- WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
- GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
-}
-
-func (v *wecomappConfigV0) ToWeComAppConfig() (WeComAppConfig, *WeComAppSecurity) {
- var sec *WeComAppSecurity
- if v.CorpSecret != "" || v.Token != "" || v.EncodingAESKey != "" {
- sec = &WeComAppSecurity{
- CorpSecret: v.CorpSecret,
- Token: v.Token,
- EncodingAESKey: v.EncodingAESKey,
- }
- }
- return WeComAppConfig{
- Enabled: v.Enabled,
- CorpID: v.CorpID,
- corpSecret: v.CorpSecret,
- AgentID: v.AgentID,
- token: v.Token,
- encodingAESKey: v.EncodingAESKey,
- WebhookHost: v.WebhookHost,
- WebhookPort: v.WebhookPort,
- WebhookPath: v.WebhookPath,
- AllowFrom: v.AllowFrom,
- ReplyTimeout: v.ReplyTimeout,
- GroupTrigger: v.GroupTrigger,
- ReasoningChannelID: v.ReasoningChannelID,
- }, sec
-}
-
-type wecomaibotConfigV0 struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
- Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
- Secret string `json:"secret" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
- EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
- WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
- AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
- ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
- MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"`
- WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
-}
-
-func (v *wecomaibotConfigV0) ToWeComAIBotConfig() (WeComAIBotConfig, *WeComAIBotSecurity) {
- var sec *WeComAIBotSecurity
- if v.Token != "" || v.Secret != "" || v.EncodingAESKey != "" {
- sec = &WeComAIBotSecurity{
- Token: v.Token,
- Secret: v.Secret,
- EncodingAESKey: v.EncodingAESKey,
- }
- }
- return WeComAIBotConfig{
- Enabled: v.Enabled,
- WebhookPath: v.WebhookPath,
- AllowFrom: v.AllowFrom,
- ReplyTimeout: v.ReplyTimeout,
- MaxSteps: v.MaxSteps,
- WelcomeMessage: v.WelcomeMessage,
- ReasoningChannelID: v.ReasoningChannelID,
- }, sec
-}
-
type picoConfigV0 struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index b356d474f..bedd46f6e 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -360,6 +360,96 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
}
}
+func TestSaveConfig_PreservesDisabledTelegramPlaceholder(t *testing.T) {
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.json")
+
+ cfg := DefaultConfig()
+ cfg.Channels.Telegram.Placeholder.Enabled = false
+
+ if err := SaveConfig(path, cfg); err != nil {
+ t.Fatalf("SaveConfig failed: %v", err)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile failed: %v", err)
+ }
+ if !strings.Contains(string(data), `"placeholder": {`) {
+ t.Fatalf("saved config should include telegram placeholder config, got: %s", string(data))
+ }
+ if !strings.Contains(string(data), `"enabled": false`) {
+ t.Fatalf("saved config should persist placeholder.enabled=false, got: %s", string(data))
+ }
+
+ loaded, err := LoadConfig(path)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+ if loaded.Channels.Telegram.Placeholder.Enabled {
+ t.Fatal("telegram placeholder should remain disabled after SaveConfig/LoadConfig round-trip")
+ }
+}
+
+// TestSaveConfig_FiltersVirtualModels verifies that SaveConfig does not write
+// virtual models (generated by expandMultiKeyModels) to the config file.
+func TestSaveConfig_FiltersVirtualModels(t *testing.T) {
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.json")
+
+ cfg := DefaultConfig()
+
+ // Manually add a virtual model to ModelList (simulating what expandMultiKeyModels does)
+ primaryModel := &ModelConfig{
+ ModelName: "gpt-4",
+ Model: "openai/gpt-4o",
+ apiKeys: []string{"key1"},
+ }
+ virtualModel := &ModelConfig{
+ ModelName: "gpt-4__key_1",
+ Model: "openai/gpt-4o",
+ apiKeys: []string{"key2"},
+ isVirtual: true,
+ }
+ cfg.ModelList = []*ModelConfig{primaryModel, virtualModel}
+
+ // SaveConfig should filter out virtual models
+ if err := SaveConfig(path, cfg); err != nil {
+ t.Fatalf("SaveConfig failed: %v", err)
+ }
+
+ // Reload and verify
+ reloaded, err := LoadConfig(path)
+ if err != nil {
+ t.Fatalf("LoadConfig failed: %v", err)
+ }
+
+ // Should only have the primary model, not the virtual one
+ if len(reloaded.ModelList) != 1 {
+ t.Fatalf("expected 1 model after reload, got %d", len(reloaded.ModelList))
+ }
+
+ if reloaded.ModelList[0].ModelName != "gpt-4" {
+ t.Errorf("expected model_name 'gpt-4', got %q", reloaded.ModelList[0].ModelName)
+ }
+
+ // Verify virtual model was not persisted
+ for _, m := range reloaded.ModelList {
+ if m.ModelName == "gpt-4__key_1" {
+ t.Errorf("virtual model gpt-4__key_1 should not have been saved")
+ }
+ }
+
+ // Verify the saved file does not contain the virtual model name
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile failed: %v", err)
+ }
+ if strings.Contains(string(data), "gpt-4__key_1") {
+ t.Errorf("saved config should not contain virtual model name 'gpt-4__key_1'")
+ }
+}
+
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
@@ -1372,8 +1462,7 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
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{Token: "wecom-token", EncodingAESKey: "wecom-aes-key"},
- WeComApp: &WeComAppSecurity{CorpSecret: "wecom-app-secret", Token: "wecom-app-token"},
+ WeCom: &WeComSecurity{Secret: "wecom-secret"},
Pico: &PicoSecurity{Token: "pico-token-abc123"},
IRC: &IRCSecurity{
Password: "irc-password",
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index c1d0ea0f6..ba1a5a0cf 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -113,6 +113,8 @@ func DefaultConfig() *Config {
Enabled: true,
Text: "Thinking... 💭",
},
+ CryptoDatabasePath: "",
+ CryptoPassphrase: "",
},
LINE: LINEConfig{
Enabled: false,
@@ -129,32 +131,11 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
WeCom: WeComConfig{
- Enabled: false,
- WebhookURL: "",
- WebhookHost: "0.0.0.0",
- WebhookPort: 18793,
- WebhookPath: "/webhook/wecom",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
- },
- WeComApp: WeComAppConfig{
- Enabled: false,
- CorpID: "",
- AgentID: 0,
- WebhookHost: "0.0.0.0",
- WebhookPort: 18792,
- WebhookPath: "/webhook/wecom-app",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
- },
- WeComAIBot: WeComAIBotConfig{
- Enabled: false,
- WebhookPath: "/webhook/wecom-aibot",
- AllowFrom: FlexibleStringSlice{},
- ReplyTimeout: 5,
- MaxSteps: 10,
- WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
- ProcessingMessage: DefaultWeComAIBotProcessingMessage,
+ Enabled: false,
+ BotID: "",
+ WebSocketURL: "wss://openws.work.weixin.qq.com",
+ SendThinkingMessage: true,
+ AllowFrom: FlexibleStringSlice{},
},
Weixin: WeixinConfig{
Enabled: false,
diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go
index cba76c6bc..42a1831b0 100644
--- a/pkg/config/example_security_usage.go
+++ b/pkg/config/example_security_usage.go
@@ -11,20 +11,33 @@ Package config
# Example: Using Security Configuration
-## 1. Create security.yml
+## Overview
-File: ~/.picoclaw/security.yml
+The security configuration feature allows you to separate sensitive data (API keys,
+tokens, secrets, passwords) from your main configuration. The system automatically
+loads values from `.security.yml` and applies them to the corresponding fields in
+your config.
+
+**Key Points:**
+- Values from `.security.yml` are automatically mapped to config fields
+- No `ref:` syntax is needed - just omit sensitive fields from config.json
+- If a field exists in both files, `.security.yml` value takes precedence
+- You can mix direct values in config.json with security values
+
+## 1. Create .security.yml
+
+File: ~/.picoclaw/.security.yml
```yaml
# Model API Keys
-# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
-# Single key should be provided as an array with one element
+# All models MUST use 'api_keys' (plural) array format
+# Even a single key must be provided as an array with one element
model_list:
gpt-5.4:
api_keys:
- "sk-proj-your-actual-openai-key-1"
- - "sk-proj-your-actual-openai-key-2" # Failover key
+ - "sk-proj-your-actual-openai-key-2" # Optional: Multiple keys for failover
claude-sonnet-4.6:
api_keys:
- "sk-ant-your-actual-anthropic-key" # Single key in array format
@@ -38,80 +51,95 @@ channels:
token: "your-discord-bot-token"
# Web Tool Keys
-# Note: Use 'api_keys' array for multiple keys (load balancing/failover)
-# For GLMSearch, use 'api_key' (single string)
+# Brave, Tavily, Perplexity: Use 'api_keys' array
+# GLMSearch, BaiduSearch: Use 'api_key' single string
web:
brave:
api_keys:
- "BSAyour-brave-api-key-1"
- - "BSAyour-brave-api-key-2" # Failover key
+ - "BSAyour-brave-api-key-2" # Optional: Multiple keys for failover
tavily:
api_keys:
- "tvly-your-tavily-api-key" # Single key in array format
+ perplexity:
+ api_keys:
+ - "pplx-your-perplexity-api-key" # Single key in array format
glm_search:
api_key: "your-glm-search-api-key" # Single key (not array)
+ baidu_search:
+ api_key: "your-baidu-search-api-key" # Single key (not array)
```
-## 2. Update config.json to use references
+## 2. Simplify config.json
File: ~/.picoclaw/config.json
+Note: Sensitive fields are omitted because they're loaded from .security.yml
+
```json
- {
- "version": 1,
- "agents": {
- "defaults": {
- "workspace": "~/picoclaw-workspace",
- "model_name": "gpt-5.4"
- }
- },
- "model_list": [
- {
- "model_name": "gpt-5.4",
- "model": "openai/gpt-5.4",
- "api_base": "https://api.openai.com/v1",
- "api_key": "ref:model_list.gpt-5.4.api_key"
- },
- {
- "model_name": "claude-sonnet-4.6",
- "model": "anthropic/claude-sonnet-4.6",
- "api_base": "https://api.anthropic.com/v1",
- "api_key": "ref:model_list.claude-sonnet-4.6.api_key"
- }
- ],
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "ref:channels.telegram.token"
- },
- "discord": {
- "enabled": true,
- "token": "ref:channels.discord.token"
- }
- },
+ {
+ "version": 1,
+ "agents": {
+ "defaults": {
+ "workspace": "~/picoclaw-workspace",
+ "model_name": "gpt-5.4"
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
+ "api_base": "https://api.openai.com/v1"
+ // api_key is automatically loaded from .security.yml
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_base": "https://api.anthropic.com/v1"
+ // api_key is automatically loaded from .security.yml
+ }
+ ],
+ "channels": {
+ "telegram": {
+ "enabled": true
+ // token is automatically loaded from .security.yml
+ },
+ "discord": {
+ "enabled": true
+ // token is automatically loaded from .security.yml
+ }
+ },
"tools": {
"web": {
"brave": {
- "enabled": true,
- "api_key": "ref:web.brave.api_key"
+ "enabled": true
+ // api_key is automatically loaded from .security.yml
},
"tavily": {
- "enabled": true,
- "api_key": "ref:web.tavily.api_key"
+ "enabled": true
+ // api_key is automatically loaded from .security.yml
+ },
+ "glm_search": {
+ "enabled": true
+ // api_key is automatically loaded from .security.yml
+ },
+ "baidu_search": {
+ "enabled": true
+ // api_key is automatically loaded from .security.yml
}
}
}
- }
+ }
```
## 3. Set proper permissions
```bash
-chmod 600 ~/.picoclaw/security.yml
+chmod 600 ~/.picoclaw/.security.yml
```
## 4. Add to .gitignore
@@ -127,57 +155,131 @@ chmod 600 ~/.picoclaw/security.yml
picoclaw --version
```
-# Available Reference Paths
+# Supported Fields in .security.yml
## Model API Keys
-- ref:model_list..api_key
+
+All models MUST use the `api_keys` (plural) array format in .security.yml.
+
+```yaml
+model_list:
+
+ :
+ api_keys:
+ - "key-1"
+ - "key-2" # Optional: Multiple keys for failover
+
+```
Examples:
-- ref:model_list.gpt-5.4.api_key
-- ref:model_list.claude-sonnet-4.6.api_key
+```yaml
+model_list:
-**Note:** In .security.yml, use `api_keys` (array) format for models.
-Both single and multiple keys should use the array format.
+ gpt-5.4:
+ api_keys:
+ - "sk-proj-key-1"
+ - "sk-proj-key-2"
+ claude-sonnet-4.6:
+ api_keys:
+ - "sk-ant-key"
+
+```
+
+**Important:**
+- Always use `api_keys` (plural) for models
+- Even a single key must be in an array format
+- The model_name in .security.yml must match the model_name in config.json
## Channel Tokens/Secrets
-- ref:channels.telegram.token
-- ref:channels.feishu.app_secret
-- ref:channels.feishu.encrypt_key
-- ref:channels.feishu.verification_token
-- ref:channels.discord.token
-- ref:channels.qq.app_secret
-- ref:channels.dingtalk.client_secret
-- ref:channels.slack.bot_token
-- ref:channels.slack.app_token
-- ref:channels.matrix.access_token
-- ref:channels.line.channel_secret
-- ref:channels.line.channel_access_token
-- ref:channels.onebot.access_token
-- ref:channels.wecom.token
-- ref:channels.wecom.encoding_aes_key
-- ref:channels.wecom_app.corp_secret
-- ref:channels.wecom_app.token
-- ref:channels.wecom_app.encoding_aes_key
-- ref:channels.wecom_aibot.token
-- ref:channels.wecom_aibot.encoding_aes_key
-- ref:channels.pico.token
-- ref:channels.irc.password
-- ref:channels.irc.nickserv_password
-- ref:channels.irc.sasl_password
+
+```yaml
+channels:
+
+ telegram:
+ token: "value"
+ feishu:
+ app_secret: "value"
+ encrypt_key: "value"
+ verification_token: "value"
+ discord:
+ token: "value"
+ weixin:
+ token: "value"
+ qq:
+ app_secret: "value"
+ dingtalk:
+ client_secret: "value"
+ slack:
+ bot_token: "value"
+ app_token: "value"
+ matrix:
+ access_token: "value"
+ line:
+ channel_secret: "value"
+ channel_access_token: "value"
+ onebot:
+ access_token: "value"
+ wecom:
+ token: "value"
+ encoding_aes_key: "value"
+ wecom_app:
+ corp_secret: "value"
+ token: "value"
+ encoding_aes_key: "value"
+ wecom_aibot:
+ secret: "value"
+ token: "value"
+ encoding_aes_key: "value"
+ pico:
+ token: "value"
+ irc:
+ password: "value"
+ nickserv_password: "value"
+ sasl_password: "value"
## Web Tool API Keys
-- ref:web.brave.api_key
-- ref:web.tavily.api_key
-- ref:web.perplexity.api_key
-- ref:web.glm_search.api_key
-**Note:**
-- Brave, Tavily, Perplexity: Use `api_keys` (array) format in .security.yml
-- GLMSearch: Use `api_key` (single string) format in .security.yml
+**Brave, Tavily, Perplexity:**
+```yaml
+web:
+
+ brave:
+ api_keys:
+ - "BSA-key-1"
+ - "BSA-key-2"
+ tavily:
+ api_keys:
+ - "tvly-key"
+ perplexity:
+ api_keys:
+ - "pplx-key"
+
+```
+Use `api_keys` (plural) array format.
+
+**GLMSearch, BaiduSearch:**
+```yaml
+web:
+
+ glm_search:
+ api_key: "your-glm-key"
+ baidu_search:
+ api_key: "your-baidu-key"
+
+```
+Use `api_key` (singular) single string format.
## Skills Registry Tokens
-- ref:skills.github.token
-- ref:skills.clawhub.auth_token
+
+```yaml
+skills:
+
+ github:
+ token: "value"
+ clawhub:
+ auth_token: "value"
+
+```
# Backward Compatibility
@@ -191,14 +293,14 @@ You can still use direct values in config.json if needed:
"model_name": "local-model",
"model": "ollama/llama3",
"api_base": "http://localhost:11434/v1",
- "api_key": "ollama" // Direct value (no reference)
+ "api_key": "ollama" // Direct value (works fine)
}
]
}
```
-You can also mix references and direct values:
+You can also mix security values and direct values:
```json
@@ -206,10 +308,12 @@ You can also mix references and direct values:
"model_list": [
{
"model_name": "cloud-model",
- "api_key": "ref:model_list.cloud-model.api_key" // From .security.yml
+ // api_key loaded from .security.yml
},
{
"model_name": "local-model",
+ "model": "ollama/llama3",
+ "api_base": "http://localhost:11434/v1",
"api_key": "ollama" // Direct value
}
]
@@ -217,6 +321,11 @@ You can also mix references and direct values:
```
+**Priority Order:**
+1. Environment variables (highest priority)
+2. .security.yml values
+3. config.json direct values (lowest priority)
+
# Migration from Old Config
## Step 1: Backup your config
@@ -224,7 +333,7 @@ You can also mix references and direct values:
cp ~/.picoclaw/config.json ~/.picoclaw/config.json.backup
```
-## Step 2: Copy the example security file
+## Step 2: Create .security.yml
```bash
cp security.example.yml ~/.picoclaw/.security.yml
```
@@ -232,10 +341,19 @@ cp security.example.yml ~/.picoclaw/.security.yml
## Step 3: Fill in your API keys
Edit ~/.picoclaw/.security.yml and replace placeholders with your actual keys.
-## Step 4: Update config.json references
-Replace sensitive values in ~/.picoclaw/config.json with ref: references.
+## Step 4: Simplify config.json (Recommended)
+Remove sensitive fields from ~/.picoclaw/config.json:
+- `api_key` fields from model_list entries
+- `token` fields from channels
+- `api_key` fields from tools.web
+- `token`/`auth_token` fields from tools.skills
-## Step 5: Test
+## Step 5: Set permissions
+```bash
+chmod 600 ~/.picoclaw/.security.yml
+```
+
+## Step 6: Test
```bash
picoclaw --version
```
@@ -249,9 +367,11 @@ rm ~/.picoclaw/config.json.backup
## Multiple API Keys (Load Balancing & Failover)
-You can configure multiple API keys for both models and web tools to enable:
+You can configure multiple API keys for models and web tools to enable:
- **Load balancing**: Requests are distributed across multiple keys
- **Failover**: If a key fails, the system automatically switches to another key
+- **Rate limit management**: Distribute usage across multiple keys
+- **High availability**: Reduce downtime during API provider issues
### Example: Model with Multiple Keys
@@ -275,7 +395,7 @@ model_list:
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
- "api_key": "ref:model_list.gpt-5.4.api_key"
+ "api_base": "https://api.openai.com/v1"
}
]
}
@@ -307,8 +427,13 @@ web:
"tools": {
"web": {
"brave": {
- "enabled": true,
- "api_key": "ref:web.brave.api_key"
+ "enabled": true
+ },
+ "tavily": {
+ "enabled": true
+ },
+ "glm_search": {
+ "enabled": true
}
}
}
@@ -316,9 +441,9 @@ web:
```
-### Single Key
+## Single Key Format
-Use array format with one element:
+**Models, Brave, Tavily, Perplexity:**
```yaml
model_list:
@@ -328,36 +453,32 @@ model_list:
```
-### Multiple Keys (Load Balancing & Failover)
-
-Use array format with multiple elements:
+**GLMSearch, BaiduSearch:**
```yaml
-model_list:
+web:
- gpt-5.4:
- api_keys:
- - "sk-proj-key-1"
- - "sk-proj-key-2"
- - "sk-proj-key-3"
+ glm_search:
+ api_key: "your-glm-key" # Single key (not array)
```
-**Important:** All model keys in .security.yml must use the `api_keys` (plural) array format.
-The single `api_key` (singular) format is NOT supported for models.
-
-### Model Index Matching
+## Model Name Matching
The system supports intelligent model name matching in .security.yml:
-**Example 1: Exact Match**
-```yaml
-# config.json
+### Example 1: Exact Match
+
+**config.json:**
+```json
{
"model_name": "gpt-5.4:0"
}
-# .security.yml (exact match with index)
+```
+
+**.security.yml (exact match with index):**
+```yaml
model_list:
gpt-5.4:0:
@@ -365,26 +486,30 @@ model_list:
```
-**Example 2: Base Name Match**
-```yaml
-# config.json
+### Example 2: Base Name Match
+
+**config.json:**
+```json
{
"model_name": "gpt-5.4:0"
}
-# .security.yml (base name without index)
+```
+
+**.security.yml (base name without index):**
+```yaml
model_list:
gpt-5.4:
- api_keys: ["key-1"]
+ api_keys: ["key-1", "key-2"]
```
Both methods work. The base name match allows you to use simpler keys in .security.yml
even when your config uses indexed model names for load balancing.
-### Security File Permissions
+## Security File Permissions
The security file should have restricted permissions:
@@ -397,26 +522,64 @@ This ensures only the owner can read and write the file.
# Security Best Practices
1. Never commit .security.yml to version control
-2. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
-3. Use different keys for different environments
-4. Rotate keys regularly and update .security.yml
-5. Encrypt backups containing .security.yml
+2. Add .security.yml to your .gitignore file
+3. Set file permissions: chmod 600 ~/.picoclaw/.security.yml
+4. Use different keys for different environments (dev, staging, production)
+5. Rotate keys regularly and update .security.yml
+6. Encrypt backups containing .security.yml
+7. Review access regularly
+
+# Environment Variables
+
+You can override any security value using environment variables:
+
+```bash
+# Channels
+export PICOCLAW_CHANNELS_TELEGRAM_TOKEN="token-from-env"
+export PICOCLAW_CHANNELS_DISCORD_TOKEN="discord-token-from-env"
+
+# Web Tools
+export PICOCLAW_TOOLS_WEB_BRAVE_API_KEY="brave-key-from-env"
+export PICOCLAW_TOOLS_WEB_BAIDU_API_KEY="baidu-key-from-env"
+
+# Skills
+export PICOCLAW_TOOLS_SKILLS_GITHUB_TOKEN="github-token-from-env"
+```
+
+Environment variables have the highest priority and will override both config.json
+and .security.yml values.
# Troubleshooting
+## Error: "failed to load security config"
+- Ensure .security.yml exists in the same directory as config.json
+- Check YAML syntax is valid (use a YAML validator)
+- Verify file permissions allow reading
+
## Error: "model security entry not found"
- Check that the model name in config.json matches exactly in .security.yml
- Verify the model_list section exists in .security.yml
+- For indexed names (e.g., "gpt-5.4:0"), check both exact match and base name match
+- Ensure the YAML structure is correct (proper indentation)
-## Error: "failed to load security config"
-- Ensure .security.yml exists in the same directory as config.json
-- Check YAML syntax is valid
-- Verify file permissions allow reading
+## Multiple API Keys Not Working
+- Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch)
+- Check that the array format is correct in YAML (proper indentation with dashes)
+- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format)
+- GLMSearch and BaiduSearch MUST use `api_key` (single string format)
-## Error: "unknown reference path"
-- Verify the reference format is correct
-- Check the path structure matches the examples above
-- Ensure all required sections exist in .security.yml
+## Keys Not Being Applied
+- Check that .security.yml is in the same directory as config.json
+- Verify the file permissions allow reading (chmod 600 ~/.picoclaw/.security.yml)
+- Ensure the YAML structure matches the expected format
+- Check for typos in field names (case-sensitive)
+- Verify the model/channel names match exactly (case-sensitive)
+
+## Load Balancing/Failover Issues
+- Verify all API keys in the api_keys array are valid
+- Check that all keys have the same rate limits and permissions
+- Monitor logs to see which keys are being used and failing
+- Ensure the api_keys array is properly formatted in YAML
*/
package config
diff --git a/pkg/config/multikey_test.go b/pkg/config/multikey_test.go
index cc529905c..c17fcc53b 100644
--- a/pkg/config/multikey_test.go
+++ b/pkg/config/multikey_test.go
@@ -232,6 +232,78 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
}
}
+func TestExpandMultiKeyModels_IsVirtualFlag(t *testing.T) {
+ models := []*ModelConfig{
+ {
+ ModelName: "gpt-4",
+ Model: "openai/gpt-4o",
+ apiKeys: []string{"key1", "key2", "key3"},
+ },
+ }
+
+ result := expandMultiKeyModels(models)
+
+ // Should expand to 3 models
+ if len(result) != 3 {
+ t.Fatalf("expected 3 models, got %d", len(result))
+ }
+
+ // Primary model should NOT be virtual
+ primary := result[2]
+ if primary.isVirtual {
+ t.Errorf("primary model should not be virtual")
+ }
+ if primary.ModelName != "gpt-4" {
+ t.Errorf("expected primary model_name 'gpt-4', got %q", primary.ModelName)
+ }
+
+ // Virtual models should have isVirtual = true
+ virtual1 := result[0]
+ if !virtual1.isVirtual {
+ t.Errorf("gpt-4__key_1 should be virtual")
+ }
+ if virtual1.ModelName != "gpt-4__key_1" {
+ t.Errorf("expected virtual model_name 'gpt-4__key_1', got %q", virtual1.ModelName)
+ }
+
+ virtual2 := result[1]
+ if !virtual2.isVirtual {
+ t.Errorf("gpt-4__key_2 should be virtual")
+ }
+ if virtual2.ModelName != "gpt-4__key_2" {
+ t.Errorf("expected virtual model_name 'gpt-4__key_2', got %q", virtual2.ModelName)
+ }
+
+ // IsVirtual() method should work
+ if !virtual1.IsVirtual() {
+ t.Errorf("IsVirtual() should return true for virtual model")
+ }
+ if primary.IsVirtual() {
+ t.Errorf("IsVirtual() should return false for primary model")
+ }
+}
+
+func TestExpandMultiKeyModels_SingleKey_NotVirtual(t *testing.T) {
+ models := []*ModelConfig{
+ {
+ ModelName: "gpt-4",
+ Model: "openai/gpt-4o",
+ apiKeys: []string{"single-key"},
+ },
+ }
+
+ result := expandMultiKeyModels(models)
+
+ if len(result) != 1 {
+ t.Fatalf("expected 1 model, got %d", len(result))
+ }
+
+ // Single key model should NOT be virtual
+ if result[0].isVirtual {
+ t.Errorf("single key model should not be virtual")
+ }
+}
+
func TestMergeAPIKeys(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/config/security.go b/pkg/config/security.go
index da989ca88..47ad1a5b0 100644
--- a/pkg/config/security.go
+++ b/pkg/config/security.go
@@ -69,21 +69,19 @@ type ModelSecurityEntry struct {
// 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"`
- WeComApp *WeComAppSecurity `yaml:"wecom_app,omitempty"`
- WeComAIBot *WeComAIBotSecurity `yaml:"wecom_aibot,omitempty"`
- Pico *PicoSecurity `yaml:"pico,omitempty"`
- IRC *IRCSecurity `yaml:"irc,omitempty"`
+ 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 {
@@ -131,20 +129,7 @@ type OneBotSecurity struct {
}
type WeComSecurity struct {
- Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
- EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
-}
-
-type WeComAppSecurity struct {
- CorpSecret string `yaml:"corp_secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
- Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
- EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
-}
-
-type WeComAIBotSecurity struct {
- Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_SECRET"`
- Token string `yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
- EncodingAESKey string `yaml:"encoding_aes_key,omitempty" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
+ Secret string `yaml:"secret,omitempty" env:"PICOCLAW_CHANNELS_WECOM_SECRET"`
}
type PicoSecurity struct {
@@ -334,17 +319,9 @@ func mergeChannelsSecurity(dst, src *ChannelsSecurity) {
if src.OneBot != nil && src.OneBot.AccessToken != "" {
dst.OneBot = src.OneBot
}
- if src.WeCom != nil && (src.WeCom.Token != "" || src.WeCom.EncodingAESKey != "") {
+ if src.WeCom != nil && src.WeCom.Secret != "" {
dst.WeCom = src.WeCom
}
- if src.WeComApp != nil &&
- (src.WeComApp.CorpSecret != "" || src.WeComApp.Token != "" || src.WeComApp.EncodingAESKey != "") {
- dst.WeComApp = src.WeComApp
- }
- if src.WeComAIBot != nil &&
- (src.WeComAIBot.Secret != "" || src.WeComAIBot.Token != "" || src.WeComAIBot.EncodingAESKey != "") {
- dst.WeComAIBot = src.WeComAIBot
- }
if src.Pico != nil && src.Pico.Token != "" {
dst.Pico = src.Pico
}
diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go
index 8f4f222e5..002988f2f 100644
--- a/pkg/config/security_integration_test.go
+++ b/pkg/config/security_integration_test.go
@@ -242,15 +242,7 @@ func TestAllSecurityKeysAccessible(t *testing.T) {
},
"wecom": {
"enabled": true,
- "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook"
- },
- "wecom_app": {
- "enabled": true,
- "corp_id": "test_corp_id",
- "agent_id": 123456
- },
- "wecom_aibot": {
- "enabled": true
+ "bot_id": "test_wecom_bot_id"
},
"pico": {
"enabled": true
@@ -317,15 +309,7 @@ channels:
onebot:
access_token: "onebot_test_access_token"
wecom:
- token: "wecom_test_webhook_token"
- encoding_aes_key: "wecom_test_aes_key"
- wecom_app:
- corp_secret: "wecom_app_test_corp_secret"
- token: "wecom_app_test_token"
- encoding_aes_key: "wecom_app_test_aes_key"
- wecom_aibot:
- token: "wecom_aibot_test_token"
- encoding_aes_key: "wecom_aibot_test_aes_key"
+ secret: "wecom_test_secret"
pico:
token: "pico_test_token"
irc:
@@ -411,24 +395,10 @@ skills:
t.Logf("OneBot AccessToken(): %s", cfg.Channels.OneBot.AccessToken())
// WeCom
- assert.Equal(t, "wecom_test_webhook_token", cfg.Channels.WeCom.Token())
- assert.Equal(t, "wecom_test_aes_key", cfg.Channels.WeCom.EncodingAESKey())
- t.Logf("WeCom Token(): %s", cfg.Channels.WeCom.Token())
- t.Logf("WeCom EncodingAESKey(): %s", cfg.Channels.WeCom.EncodingAESKey())
-
- // WeCom App
- assert.Equal(t, "wecom_app_test_corp_secret", cfg.Channels.WeComApp.CorpSecret())
- assert.Equal(t, "wecom_app_test_token", cfg.Channels.WeComApp.Token())
- assert.Equal(t, "wecom_app_test_aes_key", cfg.Channels.WeComApp.EncodingAESKey())
- t.Logf("WeComApp CorpSecret(): %s", cfg.Channels.WeComApp.CorpSecret())
- t.Logf("WeComApp Token(): %s", cfg.Channels.WeComApp.Token())
- t.Logf("WeComApp EncodingAESKey(): %s", cfg.Channels.WeComApp.EncodingAESKey())
-
- // WeCom AI Bot
- assert.Equal(t, "wecom_aibot_test_token", cfg.Channels.WeComAIBot.Token())
- assert.Equal(t, "wecom_aibot_test_aes_key", cfg.Channels.WeComAIBot.EncodingAESKey())
- t.Logf("WeComAIBot Token(): %s", cfg.Channels.WeComAIBot.Token())
- t.Logf("WeComAIBot EncodingAESKey(): %s", cfg.Channels.WeComAIBot.EncodingAESKey())
+ assert.Equal(t, "test_wecom_bot_id", cfg.Channels.WeCom.BotID)
+ assert.Equal(t, "wecom_test_secret", cfg.Channels.WeCom.Secret())
+ t.Logf("WeCom BotID: %s", cfg.Channels.WeCom.BotID)
+ t.Logf("WeCom Secret(): %s", cfg.Channels.WeCom.Secret())
// Pico
assert.Equal(t, "pico_test_token", cfg.Channels.Pico.Token())
diff --git a/pkg/gateway/channel_matrix.go b/pkg/gateway/channel_matrix.go
new file mode 100644
index 000000000..f753c60e2
--- /dev/null
+++ b/pkg/gateway/channel_matrix.go
@@ -0,0 +1,21 @@
+//go:build !mipsle && !netbsd
+
+package gateway
+
+import (
+ // Matrix currently pulls in mautrix crypto and modernc sqlite transitively.
+ //
+ // We exclude it on:
+ // - linux/mipsle: mautrix crypto falls back to libolm when the `goolm` build
+ // tag is unavailable, and modernc.org/sqlite/modernc.org/libc also lacks a
+ // working build path for our mipsle + softfloat target.
+ // - netbsd/*: modernc.org/sqlite v1.46.1 fails to compile due to broken
+ // generated mutex code on NetBSD (for example sqlite_netbsd_amd64.go calls
+ // mu.enter/mu.leave, but the generated mutex type does not define them).
+ //
+ // This means Matrix is currently unavailable on those targets. The proper
+ // long-term fix is to split Matrix basic support from its E2EE/sqlite-backed
+ // crypto path, or to upgrade/replace the upstream sqlite dependency once the
+ // affected targets are supported.
+ _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
+)
diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go
index fc2465747..03d7dfe0c 100644
--- a/pkg/gateway/gateway.go
+++ b/pkg/gateway/gateway.go
@@ -20,7 +20,6 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
- _ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index 337c950d0..938f15b80 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -13,17 +13,16 @@ var migrateableDirs = []string{
}
var supportedChannels = map[string]bool{
- "whatsapp": true,
- "telegram": true,
- "feishu": true,
- "discord": true,
- "maixcam": true,
- "qq": true,
- "dingtalk": true,
- "slack": true,
- "matrix": true,
- "line": true,
- "onebot": true,
- "wecom": true,
- "wecom_app": true,
+ "whatsapp": true,
+ "telegram": true,
+ "feishu": true,
+ "discord": true,
+ "maixcam": true,
+ "qq": true,
+ "dingtalk": true,
+ "slack": true,
+ "matrix": true,
+ "line": true,
+ "onebot": true,
+ "wecom": true,
}
diff --git a/pkg/tools/mcp_tool.go b/pkg/tools/mcp_tool.go
index 6e53cf354..5bffb4e89 100644
--- a/pkg/tools/mcp_tool.go
+++ b/pkg/tools/mcp_tool.go
@@ -5,9 +5,13 @@ import (
"encoding/json"
"fmt"
"hash/fnv"
+ "os"
"strings"
+ "time"
"github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/media"
)
// MCPManager defines the interface for MCP manager operations
@@ -25,6 +29,7 @@ type MCPTool struct {
manager MCPManager
serverName string
tool *mcp.Tool
+ mediaStore media.MediaStore
}
// NewMCPTool creates a new MCP tool wrapper
@@ -36,6 +41,10 @@ func NewMCPTool(manager MCPManager, serverName string, tool *mcp.Tool) *MCPTool
}
}
+func (t *MCPTool) SetMediaStore(store media.MediaStore) {
+ t.mediaStore = store
+}
+
// sanitizeIdentifierComponent normalizes a string so it can be safely used
// as part of a tool/function identifier for downstream providers.
// It:
@@ -218,13 +227,7 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult
WithError(fmt.Errorf("MCP tool error: %s", errMsg))
}
- // Extract text content from result
- output := extractContentText(result.Content)
-
- return &ToolResult{
- ForLLM: output,
- IsError: false,
- }
+ return t.normalizeResultContent(ctx, result.Content)
}
// extractContentText extracts text from MCP content array
@@ -233,14 +236,269 @@ func extractContentText(content []mcp.Content) string {
for _, c := range content {
switch v := c.(type) {
case *mcp.TextContent:
- parts = append(parts, v.Text)
+ parts = append(parts, sanitizeToolLLMContent(v.Text))
case *mcp.ImageContent:
- // For images, just indicate that an image was returned
- parts = append(parts, fmt.Sprintf("[Image: %s]", v.MIMEType))
+ parts = append(parts, fmt.Sprintf("[Image: %s]", normalizedMIMEType(v.MIMEType)))
+ case *mcp.AudioContent:
+ parts = append(parts, fmt.Sprintf("[Audio: %s]", normalizedMIMEType(v.MIMEType)))
+ case *mcp.ResourceLink:
+ parts = append(parts, summarizeResourceLink(v))
+ case *mcp.EmbeddedResource:
+ parts = append(parts, summarizeEmbeddedResource(v))
default:
// For other content types, use string representation
parts = append(parts, fmt.Sprintf("[Content: %T]", v))
}
}
- return strings.Join(parts, "\n")
+ return sanitizeToolLLMContent(strings.Join(parts, "\n"))
+}
+
+func (t *MCPTool) normalizeResultContent(ctx context.Context, content []mcp.Content) *ToolResult {
+ llmParts := make([]string, 0, len(content))
+ mediaRefs := make([]string, 0, len(content))
+
+ for _, c := range content {
+ switch v := c.(type) {
+ case *mcp.TextContent:
+ text := strings.TrimSpace(sanitizeToolLLMContent(v.Text))
+ if text != "" {
+ llmParts = append(llmParts, text)
+ }
+ case *mcp.ImageContent:
+ ref, note := t.storeBinaryContent(
+ ctx,
+ "image",
+ normalizedMIMEType(v.MIMEType),
+ v.Data,
+ v.Annotations,
+ )
+ if ref != "" {
+ mediaRefs = append(mediaRefs, ref)
+ }
+ if note != "" {
+ llmParts = append(llmParts, note)
+ }
+ case *mcp.AudioContent:
+ ref, note := t.storeBinaryContent(
+ ctx,
+ "audio",
+ normalizedMIMEType(v.MIMEType),
+ v.Data,
+ v.Annotations,
+ )
+ if ref != "" {
+ mediaRefs = append(mediaRefs, ref)
+ }
+ if note != "" {
+ llmParts = append(llmParts, note)
+ }
+ case *mcp.ResourceLink:
+ llmParts = append(llmParts, summarizeResourceLink(v))
+ case *mcp.EmbeddedResource:
+ ref, note := t.storeEmbeddedResource(ctx, v)
+ if ref != "" {
+ mediaRefs = append(mediaRefs, ref)
+ }
+ if note != "" {
+ llmParts = append(llmParts, note)
+ }
+ default:
+ llmParts = append(llmParts, fmt.Sprintf("[MCP returned unsupported content type %T]", v))
+ }
+ }
+
+ result := &ToolResult{
+ ForLLM: strings.Join(compactStrings(llmParts), "\n"),
+ Media: mediaRefs,
+ }
+ return result
+}
+
+func (t *MCPTool) storeEmbeddedResource(ctx context.Context, content *mcp.EmbeddedResource) (string, string) {
+ if content == nil || content.Resource == nil {
+ return "", "[MCP returned an embedded resource without data.]"
+ }
+
+ resource := content.Resource
+ if len(resource.Blob) > 0 {
+ return t.storeBinaryContent(
+ ctx,
+ "resource",
+ normalizedMIMEType(resource.MIMEType),
+ resource.Blob,
+ content.Annotations,
+ )
+ }
+
+ if strings.TrimSpace(resource.Text) != "" {
+ return "", sanitizeToolLLMContent(resource.Text)
+ }
+
+ return "", summarizeEmbeddedResource(content)
+}
+
+func (t *MCPTool) storeBinaryContent(
+ ctx context.Context,
+ kind string,
+ mimeType string,
+ data []byte,
+ annotations *mcp.Annotations,
+) (string, string) {
+ if len(data) == 0 {
+ return "", fmt.Sprintf("[MCP returned %s content (%s) but it was empty.]", kind, mimeType)
+ }
+ if !annotationsAllowUser(annotations) {
+ return "", fmt.Sprintf(
+ "[MCP returned %s content (%s) for non-user audience; omitted from model context.]",
+ kind,
+ mimeType,
+ )
+ }
+ if t.mediaStore == nil {
+ return "", fmt.Sprintf(
+ "[MCP returned %s content (%s); omitted from model context because media delivery is unavailable.]",
+ kind,
+ mimeType,
+ )
+ }
+
+ channel := ToolChannel(ctx)
+ chatID := ToolChatID(ctx)
+ if channel == "" || chatID == "" {
+ return "", fmt.Sprintf(
+ "[MCP returned %s content (%s); omitted from model context because no target chat was available.]",
+ kind,
+ mimeType,
+ )
+ }
+
+ dir := media.TempDir()
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
+ }
+
+ ext := extensionForMIMEType(mimeType)
+ tmpFile, err := os.CreateTemp(dir, "mcp-*"+ext)
+ if err != nil {
+ return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
+ }
+ tmpPath := tmpFile.Name()
+ if _, err = tmpFile.Write(data); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
+ }
+ if err = tmpFile.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf("[MCP returned %s content (%s) but it could not be stored.]", kind, mimeType)
+ }
+
+ scope := fmt.Sprintf(
+ "tool:mcp:%s:%s:%s:%d",
+ sanitizeIdentifierComponent(t.serverName),
+ channel,
+ chatID,
+ time.Now().UnixNano(),
+ )
+ filename := fmt.Sprintf(
+ "%s_%s%s",
+ sanitizeIdentifierComponent(t.serverName),
+ sanitizeIdentifierComponent(t.tool.Name),
+ ext,
+ )
+
+ ref, err := t.mediaStore.Store(tmpPath, media.MediaMeta{
+ Filename: filename,
+ ContentType: mimeType,
+ Source: fmt.Sprintf(
+ "tool:mcp:%s:%s",
+ sanitizeIdentifierComponent(t.serverName),
+ sanitizeIdentifierComponent(t.tool.Name),
+ ),
+ }, scope)
+ if err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf(
+ "[MCP returned %s content (%s) but it could not be registered as media.]",
+ kind,
+ mimeType,
+ )
+ }
+
+ return ref, fmt.Sprintf(
+ "[MCP returned %s content (%s); omitted from model context and stored as a local media artifact.]",
+ kind,
+ mimeType,
+ )
+}
+
+func summarizeResourceLink(content *mcp.ResourceLink) string {
+ if content == nil {
+ return "[MCP returned an empty resource link.]"
+ }
+
+ parts := []string{"[MCP returned resource link"}
+ if content.Name != "" {
+ parts = append(parts, fmt.Sprintf("name=%q", content.Name))
+ }
+ if content.URI != "" {
+ parts = append(parts, fmt.Sprintf("uri=%q", content.URI))
+ }
+ if content.MIMEType != "" {
+ parts = append(parts, fmt.Sprintf("mime=%q", content.MIMEType))
+ }
+ if content.Description != "" {
+ desc := strings.TrimSpace(content.Description)
+ if len(desc) > 200 {
+ desc = desc[:200] + "..."
+ }
+ parts = append(parts, fmt.Sprintf("description=%q", desc))
+ }
+ return strings.Join(parts, ", ") + "]"
+}
+
+func summarizeEmbeddedResource(content *mcp.EmbeddedResource) string {
+ if content == nil || content.Resource == nil {
+ return "[MCP returned an embedded resource.]"
+ }
+
+ resource := content.Resource
+ if resource.URI != "" {
+ return fmt.Sprintf(
+ "[MCP returned embedded resource %q (%s).]",
+ resource.URI,
+ normalizedMIMEType(resource.MIMEType),
+ )
+ }
+ return fmt.Sprintf("[MCP returned embedded resource (%s).]", normalizedMIMEType(resource.MIMEType))
+}
+
+func annotationsAllowUser(annotations *mcp.Annotations) bool {
+ if annotations == nil || len(annotations.Audience) == 0 {
+ return true
+ }
+ for _, audience := range annotations.Audience {
+ if strings.EqualFold(string(audience), "user") {
+ return true
+ }
+ }
+ return false
+}
+
+func normalizedMIMEType(mimeType string) string {
+ if strings.TrimSpace(mimeType) == "" {
+ return "application/octet-stream"
+ }
+ return mimeType
+}
+
+func compactStrings(parts []string) []string {
+ compact := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if strings.TrimSpace(part) == "" {
+ continue
+ }
+ compact = append(compact, part)
+ }
+ return compact
}
diff --git a/pkg/tools/mcp_tool_test.go b/pkg/tools/mcp_tool_test.go
index 95bb0f992..8bbac3bc7 100644
--- a/pkg/tools/mcp_tool_test.go
+++ b/pkg/tools/mcp_tool_test.go
@@ -3,10 +3,14 @@ package tools
import (
"context"
"fmt"
+ "os"
+ "path/filepath"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "github.com/sipeed/picoclaw/pkg/media"
)
// MockMCPManager is a mock implementation of MCPManager interface for testing
@@ -490,3 +494,143 @@ func TestMCPTool_Parameters_MapSchema(t *testing.T) {
t.Errorf("Name type should be 'string', got '%v'", nameParam["type"])
}
}
+
+func TestMCPTool_Execute_ImageContentStoredAsMedia(t *testing.T) {
+ store := media.NewFileMediaStore()
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.ImageContent{
+ Data: []byte("fake-image-bytes"),
+ MIMEType: "image/png",
+ },
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "screenshoto", &mcp.Tool{Name: "take_screenshot"})
+ mcpTool.SetMediaStore(store)
+
+ result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil)
+
+ if result.IsError {
+ t.Fatalf("expected success, got %q", result.ForLLM)
+ }
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if result.ResponseHandled {
+ t.Fatal("expected MCP image artifact not to mark response as handled")
+ }
+ if !strings.Contains(result.ForLLM, "stored as a local media artifact") {
+ t.Fatalf("expected local media artifact note, got %q", result.ForLLM)
+ }
+
+ path, meta, err := store.ResolveWithMeta(result.Media[0])
+ if err != nil {
+ t.Fatalf("expected stored media ref to resolve: %v", err)
+ }
+ if meta.ContentType != "image/png" {
+ t.Fatalf("expected image/png content type, got %q", meta.ContentType)
+ }
+ if filepath.Ext(path) != ".png" {
+ t.Fatalf("expected png temp file, got %q", path)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected stored media file to be readable: %v", err)
+ }
+ if string(data) != "fake-image-bytes" {
+ t.Fatalf("expected stored media bytes to match input, got %q", string(data))
+ }
+}
+
+func TestMCPTool_Execute_EmbeddedResourceBlobStoredAsMedia(t *testing.T) {
+ store := media.NewFileMediaStore()
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.EmbeddedResource{
+ Resource: &mcp.ResourceContents{
+ URI: "file:///tmp/report.png",
+ MIMEType: "image/png",
+ Blob: []byte("blob-bytes"),
+ },
+ },
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "grafana", &mcp.Tool{Name: "get_dashboard_image"})
+ mcpTool.SetMediaStore(store)
+
+ result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil)
+
+ if len(result.Media) != 1 {
+ t.Fatalf("expected embedded resource blob to be stored as media, got %d refs", len(result.Media))
+ }
+ path, _, err := store.ResolveWithMeta(result.Media[0])
+ if err != nil {
+ t.Fatalf("expected stored media ref to resolve: %v", err)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("expected stored media file to be readable: %v", err)
+ }
+ if string(data) != "blob-bytes" {
+ t.Fatalf("expected stored blob bytes to match input, got %q", string(data))
+ }
+}
+
+func TestMCPTool_Execute_RespectsUserAudienceForBinaryContent(t *testing.T) {
+ store := media.NewFileMediaStore()
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.ImageContent{
+ Data: []byte("assistant-only"),
+ MIMEType: "image/png",
+ Annotations: &mcp.Annotations{Audience: []mcp.Role{"assistant"}},
+ },
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "screenshoto", &mcp.Tool{Name: "take_screenshot"})
+ mcpTool.SetMediaStore(store)
+
+ result := mcpTool.Execute(WithToolContext(context.Background(), "telegram", "chat-42"), nil)
+
+ if len(result.Media) != 0 {
+ t.Fatalf("expected no media ref for non-user audience, got %d", len(result.Media))
+ }
+ if !strings.Contains(result.ForLLM, "non-user audience") {
+ t.Fatalf("expected audience note, got %q", result.ForLLM)
+ }
+}
+
+func TestMCPTool_Execute_LargeBase64TextIsOmittedFromContext(t *testing.T) {
+ manager := &MockMCPManager{
+ callToolFunc: func(ctx context.Context, serverName, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: strings.Repeat("QUJD", 400)},
+ },
+ }, nil
+ },
+ }
+
+ mcpTool := NewMCPTool(manager, "test_server", &mcp.Tool{Name: "dump_payload"})
+
+ result := mcpTool.Execute(context.Background(), nil)
+
+ if result.ForLLM != largeBase64OmittedMessage {
+ t.Fatalf("expected sanitized large base64 note, got %q", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/normalization.go b/pkg/tools/normalization.go
new file mode 100644
index 000000000..3a76c5d92
--- /dev/null
+++ b/pkg/tools/normalization.go
@@ -0,0 +1,292 @@
+package tools
+
+import (
+ "encoding/base64"
+ "fmt"
+ "mime"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/sipeed/picoclaw/pkg/media"
+)
+
+const (
+ largeBase64OmittedMessage = "[Tool returned a large base64-like payload; omitted from model context.]"
+ inlineMediaOmittedMessage = "[Tool returned inline media content; omitted from model context.]"
+ inlineMediaStoredMessage = "[Tool returned inline media content (%s); omitted from model context and registered as a media attachment.]"
+)
+
+var (
+ inlineMarkdownDataURLRe = regexp.MustCompile(`!\[[^\]]*\]\((data:[^)]+)\)`)
+ inlineRawDataURLRe = regexp.MustCompile(`data:[^;\s]+;base64,[A-Za-z0-9+/=\r\n]+`)
+)
+
+func normalizeToolResult(
+ result *ToolResult,
+ toolName string,
+ store media.MediaStore,
+ channel string,
+ chatID string,
+) *ToolResult {
+ if result == nil {
+ return nil
+ }
+
+ notes := make([]string, 0, 2)
+ seen := make(map[string]struct{})
+
+ if store != nil && channel != "" && chatID != "" {
+ var refs []string
+ var extractedNotes []string
+
+ result.ForLLM, refs, extractedNotes = extractInlineMediaRefs(
+ result.ForLLM,
+ toolName,
+ store,
+ channel,
+ chatID,
+ seen,
+ )
+ result.Media = append(result.Media, refs...)
+ notes = append(notes, extractedNotes...)
+
+ result.ForUser, refs, extractedNotes = extractInlineMediaRefs(
+ result.ForUser,
+ toolName,
+ store,
+ channel,
+ chatID,
+ seen,
+ )
+ result.Media = append(result.Media, refs...)
+ notes = append(notes, extractedNotes...)
+ }
+
+ result.ForLLM = sanitizeToolLLMContent(result.ForLLM)
+
+ if len(result.Media) > 0 && len(notes) > 0 {
+ if strings.TrimSpace(result.ForLLM) == "" {
+ result.ForLLM = strings.Join(notes, "\n")
+ } else {
+ result.ForLLM = strings.TrimSpace(result.ForLLM) + "\n" + strings.Join(notes, "\n")
+ }
+ }
+ if len(result.Media) > 0 && strings.TrimSpace(result.ForLLM) == "" {
+ result.ForLLM = "[Tool returned media content; omitted from model context and registered as a media attachment.]"
+ }
+
+ return result
+}
+
+func sanitizeToolLLMContent(text string) string {
+ trimmed := strings.TrimSpace(text)
+ if trimmed == "" {
+ return text
+ }
+ if inlineMarkdownDataURLRe.MatchString(trimmed) || inlineRawDataURLRe.MatchString(trimmed) {
+ cleaned := inlineMarkdownDataURLRe.ReplaceAllString(trimmed, "")
+ cleaned = inlineRawDataURLRe.ReplaceAllString(cleaned, "")
+ cleaned = strings.TrimSpace(cleaned)
+ if cleaned == "" {
+ return inlineMediaOmittedMessage
+ }
+ return cleaned + "\n" + inlineMediaOmittedMessage
+ }
+ if looksLikeLargeBase64Payload(trimmed) {
+ return largeBase64OmittedMessage
+ }
+ return text
+}
+
+func looksLikeLargeBase64Payload(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ if len(trimmed) < 1024 {
+ return false
+ }
+
+ nonSpace := 0
+ base64Like := 0
+ spaceCount := 0
+
+ for _, r := range trimmed {
+ if unicode.IsSpace(r) {
+ spaceCount++
+ continue
+ }
+ nonSpace++
+ if (r >= 'A' && r <= 'Z') ||
+ (r >= 'a' && r <= 'z') ||
+ (r >= '0' && r <= '9') ||
+ r == '+' || r == '/' || r == '=' {
+ base64Like++
+ }
+ }
+
+ if nonSpace == 0 {
+ return false
+ }
+
+ ratio := float64(base64Like) / float64(nonSpace)
+ return ratio >= 0.97 && spaceCount <= len(trimmed)/128
+}
+
+func extractInlineMediaRefs(
+ text string,
+ toolName string,
+ store media.MediaStore,
+ channel string,
+ chatID string,
+ seen map[string]struct{},
+) (cleaned string, refs []string, notes []string) {
+ cleaned = text
+
+ matches := inlineMarkdownDataURLRe.FindAllStringSubmatch(cleaned, -1)
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ dataURL := match[1]
+ ref, note := storeInlineDataURL(toolName, store, channel, chatID, dataURL, seen)
+ if ref != "" {
+ refs = append(refs, ref)
+ }
+ if note != "" {
+ notes = append(notes, note)
+ }
+ cleaned = strings.ReplaceAll(cleaned, match[0], "")
+ }
+
+ rawMatches := inlineRawDataURLRe.FindAllString(cleaned, -1)
+ for _, dataURL := range rawMatches {
+ ref, note := storeInlineDataURL(toolName, store, channel, chatID, dataURL, seen)
+ if ref != "" {
+ refs = append(refs, ref)
+ }
+ if note != "" {
+ notes = append(notes, note)
+ }
+ cleaned = strings.ReplaceAll(cleaned, dataURL, "")
+ }
+
+ return strings.TrimSpace(cleaned), refs, notes
+}
+
+func storeInlineDataURL(
+ toolName string,
+ store media.MediaStore,
+ channel string,
+ chatID string,
+ dataURL string,
+ seen map[string]struct{},
+) (ref string, note string) {
+ dataURL = strings.TrimSpace(dataURL)
+ if _, ok := seen[dataURL]; ok {
+ return "", ""
+ }
+ seen[dataURL] = struct{}{}
+
+ if !strings.HasPrefix(strings.ToLower(dataURL), "data:") {
+ return "", ""
+ }
+
+ comma := strings.IndexByte(dataURL, ',')
+ if comma <= 5 {
+ return "", "[Tool returned inline media content that could not be parsed.]"
+ }
+
+ metaPart := dataURL[:comma]
+ payload := dataURL[comma+1:]
+ if !strings.Contains(strings.ToLower(metaPart), ";base64") {
+ return "", "[Tool returned inline media content that was not base64-encoded.]"
+ }
+
+ mimeType := strings.TrimSpace(strings.TrimPrefix(metaPart, "data:"))
+ if semi := strings.IndexByte(mimeType, ';'); semi >= 0 {
+ mimeType = mimeType[:semi]
+ }
+ if mimeType == "" {
+ mimeType = "application/octet-stream"
+ }
+
+ payload = strings.NewReplacer("\n", "", "\r", "", "\t", "", " ", "").Replace(payload)
+ decoded, err := base64.StdEncoding.DecodeString(payload)
+ if err != nil {
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) that could not be decoded.]", mimeType)
+ }
+
+ dir := media.TempDir()
+ if err = os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType)
+ }
+
+ ext := extensionForMIMEType(mimeType)
+ tmpFile, err := os.CreateTemp(dir, "tool-inline-*"+ext)
+ if err != nil {
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType)
+ }
+ tmpPath := tmpFile.Name()
+ if _, err = tmpFile.Write(decoded); err != nil {
+ tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType)
+ }
+ if err = tmpFile.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be stored.]", mimeType)
+ }
+
+ filename := sanitizeIdentifierComponent(toolName) + ext
+ scope := fmt.Sprintf(
+ "tool:inline:%s:%s:%s:%d",
+ sanitizeIdentifierComponent(toolName),
+ channel,
+ chatID,
+ time.Now().UnixNano(),
+ )
+
+ ref, err = store.Store(tmpPath, media.MediaMeta{
+ Filename: filename,
+ ContentType: mimeType,
+ Source: fmt.Sprintf("tool:inline:%s", sanitizeIdentifierComponent(toolName)),
+ }, scope)
+ if err != nil {
+ _ = os.Remove(tmpPath)
+ return "", fmt.Sprintf("[Tool returned inline media content (%s) but it could not be registered.]", mimeType)
+ }
+
+ return ref, fmt.Sprintf(inlineMediaStoredMessage, mimeType)
+}
+
+func extensionForMIMEType(mimeType string) string {
+ if mimeType == "" {
+ return ".bin"
+ }
+ if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 {
+ return exts[0]
+ }
+
+ switch strings.ToLower(mimeType) {
+ case "image/jpeg":
+ return ".jpg"
+ case "image/png":
+ return ".png"
+ case "image/gif":
+ return ".gif"
+ case "image/webp":
+ return ".webp"
+ case "audio/wav", "audio/x-wav":
+ return ".wav"
+ case "audio/mpeg":
+ return ".mp3"
+ case "audio/ogg":
+ return ".ogg"
+ case "video/mp4":
+ return ".mp4"
+ default:
+ return filepath.Ext(mimeType)
+ }
+}
diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go
index 2c634e673..56af8d695 100644
--- a/pkg/tools/registry.go
+++ b/pkg/tools/registry.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -19,9 +20,14 @@ type ToolEntry struct {
}
type ToolRegistry struct {
- tools map[string]*ToolEntry
- mu sync.RWMutex
- version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
+ tools map[string]*ToolEntry
+ mu sync.RWMutex
+ version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation
+ mediaStore media.MediaStore
+}
+
+type mediaStoreAware interface {
+ SetMediaStore(store media.MediaStore)
}
func NewToolRegistry() *ToolRegistry {
@@ -43,6 +49,9 @@ func (r *ToolRegistry) Register(tool Tool) {
IsCore: true,
TTL: 0, // Core tools do not use TTL
}
+ if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil {
+ aware.SetMediaStore(r.mediaStore)
+ }
r.version.Add(1)
logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name})
}
@@ -61,10 +70,27 @@ func (r *ToolRegistry) RegisterHidden(tool Tool) {
IsCore: false,
TTL: 0,
}
+ if aware, ok := tool.(mediaStoreAware); ok && r.mediaStore != nil {
+ aware.SetMediaStore(r.mediaStore)
+ }
r.version.Add(1)
logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name})
}
+// SetMediaStore injects a MediaStore into all registered tools that can
+// consume it, and remembers it for future registrations.
+func (r *ToolRegistry) SetMediaStore(store media.MediaStore) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.mediaStore = store
+ for _, entry := range r.tools {
+ if aware, ok := entry.Tool.(mediaStoreAware); ok {
+ aware.SetMediaStore(store)
+ }
+ }
+}
+
// PromoteTools atomically sets the TTL for multiple non-core tools.
// This prevents a concurrent TickTTL from decrementing between promotions.
func (r *ToolRegistry) PromoteTools(names []string, ttl int) {
@@ -238,6 +264,8 @@ func (r *ToolRegistry) ExecuteWithContext(
}
}
+ result = normalizeToolResult(result, name, r.mediaStore, channel, chatID)
+
duration := time.Since(start)
// Log based on result type
@@ -259,7 +287,7 @@ func (r *ToolRegistry) ExecuteWithContext(
map[string]any{
"tool": name,
"duration_ms": duration.Milliseconds(),
- "result_length": len(result.ForLLM),
+ "result_length": len(result.ContentForLLM()),
})
}
@@ -354,7 +382,8 @@ func (r *ToolRegistry) Clone() *ToolRegistry {
r.mu.RLock()
defer r.mu.RUnlock()
clone := &ToolRegistry{
- tools: make(map[string]*ToolEntry, len(r.tools)),
+ tools: make(map[string]*ToolEntry, len(r.tools)),
+ mediaStore: r.mediaStore,
}
for name, entry := range r.tools {
clone.tools[name] = &ToolEntry{
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 967758dfa..db52749f6 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -3,10 +3,13 @@ package tools
import (
"context"
"errors"
+ "os"
+ "path/filepath"
"strings"
"sync"
"testing"
+ "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -46,6 +49,15 @@ func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]
return m.result
}
+type mockMediaStoreAwareTool struct {
+ mockRegistryTool
+ store media.MediaStore
+}
+
+func (m *mockMediaStoreAwareTool) SetMediaStore(store media.MediaStore) {
+ m.store = store
+}
+
// --- helpers ---
func newMockTool(name, desc string) *mockRegistryTool {
@@ -621,3 +633,102 @@ func TestToolRegistry_Execute_PanicDoesNotAffectOtherTools(t *testing.T) {
t.Errorf("expected 'success', got %q", result2.ForLLM)
}
}
+
+func TestToolRegistry_SetMediaStore_PropagatesToExistingAndNewTools(t *testing.T) {
+ r := NewToolRegistry()
+ store := media.NewFileMediaStore()
+
+ existing := &mockMediaStoreAwareTool{
+ mockRegistryTool: *newMockTool("existing", "existing tool"),
+ }
+ r.Register(existing)
+
+ r.SetMediaStore(store)
+ if existing.store != store {
+ t.Fatal("expected existing tool to receive media store")
+ }
+
+ later := &mockMediaStoreAwareTool{
+ mockRegistryTool: *newMockTool("later", "later tool"),
+ }
+ r.Register(later)
+
+ if later.store != store {
+ t.Fatal("expected newly registered tool to inherit media store")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SanitizesLargeBase64Payload(t *testing.T) {
+ r := NewToolRegistry()
+ payload := strings.Repeat("QUJD", 400)
+ r.Register(&mockRegistryTool{
+ name: "base64_tool",
+ desc: "returns huge base64",
+ params: map[string]any{},
+ result: SilentResult(payload),
+ })
+
+ result := r.ExecuteWithContext(context.Background(), "base64_tool", nil, "telegram", "chat-1", nil)
+
+ if result.ForLLM != largeBase64OmittedMessage {
+ t.Fatalf("expected sanitized payload, got %q", result.ForLLM)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_ExtractsInlineMediaDataURL(t *testing.T) {
+ r := NewToolRegistry()
+ store := media.NewFileMediaStore()
+ r.SetMediaStore(store)
+
+ payload := ""
+ r.Register(&mockRegistryTool{
+ name: "inline_media_tool",
+ desc: "returns inline data url",
+ params: map[string]any{},
+ result: SilentResult(payload),
+ })
+
+ result := r.ExecuteWithContext(context.Background(), "inline_media_tool", nil, "telegram", "chat-42", nil)
+
+ if len(result.Media) != 1 {
+ t.Fatalf("expected 1 media ref, got %d", len(result.Media))
+ }
+ if strings.Contains(result.ForLLM, "data:image/png;base64") {
+ t.Fatalf("expected inline data URL to be stripped from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "registered as a media attachment") {
+ t.Fatalf("expected delivery note in ForLLM, got %q", result.ForLLM)
+ }
+
+ path, err := store.Resolve(result.Media[0])
+ if err != nil {
+ t.Fatalf("expected stored media ref to resolve: %v", err)
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected stored media file to exist: %v", err)
+ }
+ if filepath.Ext(path) != ".png" {
+ t.Fatalf("expected stored inline media to use png extension, got %q", path)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SanitizesInlineMediaWithoutStore(t *testing.T) {
+ r := NewToolRegistry()
+
+ payload := "before  after"
+ r.Register(&mockRegistryTool{
+ name: "inline_media_no_store",
+ desc: "returns inline data url without store",
+ params: map[string]any{},
+ result: SilentResult(payload),
+ })
+
+ result := r.ExecuteWithContext(context.Background(), "inline_media_no_store", nil, "telegram", "chat-42", nil)
+
+ if strings.Contains(result.ForLLM, "data:image/png;base64") {
+ t.Fatalf("expected inline data URL to be removed from ForLLM, got %q", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, inlineMediaOmittedMessage) {
+ t.Fatalf("expected inline media omission note, got %q", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/result.go b/pkg/tools/result.go
index bf34b7bc6..c81213125 100644
--- a/pkg/tools/result.go
+++ b/pkg/tools/result.go
@@ -2,10 +2,16 @@ package tools
import (
"encoding/json"
+ "strings"
"github.com/sipeed/picoclaw/pkg/providers"
)
+const (
+ handledToolLLMNote = "The requested output has already been delivered to the user in the current chat. Do not call send_file or any other delivery tool again. If you reply, provide only a brief confirmation."
+ artifactPathsLLMNote = "Use `send_file` with one of these paths to send it to the user, or use file/exec tools to save it inside the workspace if requested."
+)
+
// ToolResult represents the structured return value from tool execution.
// It provides clear semantics for different types of results and supports
// async operations, user-facing messages, and error handling.
@@ -43,6 +49,48 @@ type ToolResult struct {
// Only populated by SubTurn executions; used by evaluator_optimizer
// to carry stateful worker context across evaluation iterations.
Messages []providers.Message `json:"-"`
+
+ // ArtifactTags exposes local artifact paths back to the LLM in a structured
+ // form, e.g. "[file:/tmp/example.png]". This is used when a tool produced a
+ // reusable local artifact but did not deliver it to the user yet.
+ ArtifactTags []string `json:"artifact_tags,omitempty"`
+
+ // ResponseHandled indicates that this tool execution already satisfied the
+ // user's request at the channel/output level, so the agent loop can stop
+ // without a follow-up assistant response.
+ ResponseHandled bool `json:"response_handled,omitempty"`
+}
+
+// ContentForLLM returns the normalized textual content to append to the
+// conversation after a tool call. Errors fall back to Err when ForLLM is empty.
+func (tr *ToolResult) ContentForLLM() string {
+ if tr == nil {
+ return ""
+ }
+ content := tr.ForLLM
+ if content == "" && tr.Err != nil {
+ content = tr.Err.Error()
+ }
+ if tr.ResponseHandled {
+ if content == "" {
+ return handledToolLLMNote
+ }
+ if !strings.Contains(content, handledToolLLMNote) {
+ content += "\n" + handledToolLLMNote
+ }
+ }
+ if len(tr.ArtifactTags) > 0 {
+ artifactNote := "Local artifact paths: " + strings.Join(tr.ArtifactTags, " ") + "\n" + artifactPathsLLMNote
+ if content == "" {
+ content = artifactNote
+ } else if !strings.Contains(content, artifactNote) {
+ content += "\n" + artifactNote
+ }
+ }
+ if content != "" {
+ return content
+ }
+ return ""
}
// NewToolResult creates a basic ToolResult with content for the LLM.
@@ -167,3 +215,9 @@ func (tr *ToolResult) WithError(err error) *ToolResult {
tr.Err = err
return tr
}
+
+// WithResponseHandled marks the tool result as already delivered to the user.
+func (tr *ToolResult) WithResponseHandled() *ToolResult {
+ tr.ResponseHandled = true
+ return tr
+}
diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go
index a234e33f3..5f08cb4fa 100644
--- a/pkg/tools/result_test.go
+++ b/pkg/tools/result_test.go
@@ -3,6 +3,7 @@ package tools
import (
"encoding/json"
"errors"
+ "strings"
"testing"
)
@@ -227,3 +228,41 @@ func TestToolResultJSONStructure(t *testing.T) {
t.Errorf("Expected silent false, got %v", parsed["silent"])
}
}
+
+func TestToolResultContentForLLM_AppendsHandledDeliveryNote(t *testing.T) {
+ result := MediaResult("Screenshot attached.", []string{"media://example"}).WithResponseHandled()
+
+ content := result.ContentForLLM()
+ if !strings.Contains(content, "Screenshot attached.") {
+ t.Fatalf("expected original content in ContentForLLM, got %q", content)
+ }
+ if !strings.Contains(content, handledToolLLMNote) {
+ t.Fatalf("expected handled delivery note in ContentForLLM, got %q", content)
+ }
+}
+
+func TestToolResultContentForLLM_UsesHandledDeliveryNoteWhenEmpty(t *testing.T) {
+ result := (&ToolResult{}).WithResponseHandled()
+
+ if got := result.ContentForLLM(); got != handledToolLLMNote {
+ t.Fatalf("ContentForLLM() = %q, want %q", got, handledToolLLMNote)
+ }
+}
+
+func TestToolResultContentForLLM_AppendsArtifactPaths(t *testing.T) {
+ result := &ToolResult{
+ ForLLM: "Artifact created.",
+ ArtifactTags: []string{"[file:/tmp/example.png]"},
+ }
+
+ content := result.ContentForLLM()
+ if !strings.Contains(content, "Artifact created.") {
+ t.Fatalf("expected original content in ContentForLLM, got %q", content)
+ }
+ if !strings.Contains(content, "Local artifact paths: [file:/tmp/example.png]") {
+ t.Fatalf("expected artifact path note in ContentForLLM, got %q", content)
+ }
+ if !strings.Contains(content, artifactPathsLLMNote) {
+ t.Fatalf("expected artifact guidance note in ContentForLLM, got %q", content)
+ }
+}
diff --git a/pkg/tools/send_file.go b/pkg/tools/send_file.go
index 57b99a845..44198381e 100644
--- a/pkg/tools/send_file.go
+++ b/pkg/tools/send_file.go
@@ -142,7 +142,7 @@ func (t *SendFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult(fmt.Sprintf("failed to register media: %v", err))
}
- return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref})
+ return MediaResult(fmt.Sprintf("File %q sent to user", filename), []string{ref}).WithResponseHandled()
}
// detectMediaType determines the MIME type of a file.
diff --git a/pkg/tools/send_file_test.go b/pkg/tools/send_file_test.go
index 0a99e8028..f36baf7d0 100644
--- a/pkg/tools/send_file_test.go
+++ b/pkg/tools/send_file_test.go
@@ -104,6 +104,9 @@ func TestSendFileTool_Success(t *testing.T) {
if result.Media[0][:8] != "media://" {
t.Errorf("expected media:// ref, got %q", result.Media[0])
}
+ if !result.ResponseHandled {
+ t.Fatal("expected send_file success to mark response handled")
+ }
_, meta, err := store.ResolveWithMeta(result.Media[0])
if err != nil {
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 244f0d4a2..387813e94 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -159,10 +159,7 @@ func RunToolLoop(
// Append results in original order
for _, r := range results {
- contentForLLM := r.result.ForLLM
- if contentForLLM == "" && r.result.Err != nil {
- contentForLLM = r.result.Err.Error()
- }
+ contentForLLM := r.result.ContentForLLM()
messages = append(messages, providers.Message{
Role: "tool",
diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go
index 21624d3ef..dd4c9af3d 100644
--- a/web/backend/api/channels.go
+++ b/web/backend/api/channels.go
@@ -22,8 +22,6 @@ var channelCatalog = []channelCatalogItem{
{Name: "qq", ConfigKey: "qq"},
{Name: "onebot", ConfigKey: "onebot"},
{Name: "wecom", ConfigKey: "wecom"},
- {Name: "wecom_app", ConfigKey: "wecom_app"},
- {Name: "wecom_aibot", ConfigKey: "wecom_aibot"},
{Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"},
{Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"},
{Name: "pico", ConfigKey: "pico"},
diff --git a/web/backend/api/config.go b/web/backend/api/config.go
index e67e3e6d7..618b8438d 100644
--- a/web/backend/api/config.go
+++ b/web/backend/api/config.go
@@ -6,6 +6,7 @@ import (
"io"
"net/http"
"regexp"
+ "strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -16,6 +17,7 @@ func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
+ mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
// handleGetConfig returns the complete system configuration.
@@ -179,6 +181,70 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
+// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
+//
+// POST /api/config/test-command-patterns
+func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err != nil {
+ http.Error(w, "Failed to read request body", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var req struct {
+ AllowPatterns []string `json:"allow_patterns"`
+ DenyPatterns []string `json:"deny_patterns"`
+ Command string `json:"command"`
+ }
+ if err := json.Unmarshal(body, &req); err != nil {
+ http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ lower := strings.ToLower(strings.TrimSpace(req.Command))
+
+ type result struct {
+ Allowed bool `json:"allowed"`
+ Blocked bool `json:"blocked"`
+ MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
+ MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
+ }
+
+ resp := result{Allowed: false, Blocked: false}
+
+ // Check whitelist first
+ for _, pattern := range req.AllowPatterns {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ continue // skip invalid patterns
+ }
+ if re.MatchString(lower) {
+ resp.Allowed = true
+ resp.MatchedWhitelist = &pattern
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ return
+ }
+ }
+
+ // Check blacklist
+ for _, pattern := range req.DenyPatterns {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ continue
+ }
+ if re.MatchString(lower) {
+ resp.Blocked = true
+ resp.MatchedBlacklist = &pattern
+ break
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+}
+
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
@@ -209,6 +275,15 @@ func validateConfig(cfg *config.Config) []string {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
+ if cfg.Channels.WeCom.Enabled {
+ if cfg.Channels.WeCom.BotID == "" {
+ errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
+ }
+ if cfg.Channels.WeCom.Secret() == "" {
+ errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
+ }
+ }
+
if cfg.Tools.Exec.Enabled {
if cfg.Tools.Exec.EnableDenyPatterns {
errs = append(
diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go
index 9b05546f9..36acd95b0 100644
--- a/web/backend/api/config_test.go
+++ b/web/backend/api/config_test.go
@@ -282,3 +282,170 @@ func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisable
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
+
+// testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request.
+func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": ["^echo\\s+hello"],
+ "deny_patterns": ["^rm\\s+-rf"],
+ "command": "echo hello world"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=true, body=%s", rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
+ t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": ["^echo\\s+hello"],
+ "deny_patterns": ["^rm\\s+-rf"],
+ "command": "rm -rf /tmp"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
+ t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": ["^echo\\s+hello"],
+ "deny_patterns": ["^rm\\s+-rf"],
+ "command": "ls -la"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=false, body=%s", rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
+ t.Fatalf("expected blocked=false, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": ["(?i)^ECHO"],
+ "deny_patterns": [],
+ "command": "echo hello"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": [],
+ "deny_patterns": [],
+ "command": "rm -rf /tmp"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String())
+ }
+ if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
+ t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": ["([[", "^echo"],
+ "deny_patterns": [],
+ "command": "echo hello"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) {
+ t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ rec := testCommandPatterns(t, configPath, `{
+ "allow_patterns": [],
+ "deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"],
+ "command": "echo $GITHUB_API_KEY"
+ }`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) {
+ t.Fatalf("expected blocked=true, body=%s", rec.Body.String())
+ }
+ if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) {
+ t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String())
+ }
+}
+
+func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/config/test-command-patterns",
+ bytes.NewBufferString(`{invalid json}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ mux.ServeHTTP(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+}
diff --git a/web/backend/api/models.go b/web/backend/api/models.go
index 48babd8cd..38a55948b 100644
--- a/web/backend/api/models.go
+++ b/web/backend/api/models.go
@@ -42,6 +42,7 @@ type modelResponse struct {
// Meta
Configured bool `json:"configured"`
IsDefault bool `json:"is_default"`
+ IsVirtual bool `json:"is_virtual"`
}
// handleListModels returns all model_list entries with masked API keys.
@@ -86,6 +87,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
ExtraBody: m.ExtraBody,
Configured: configured[i],
IsDefault: m.ModelName == defaultModel,
+ IsVirtual: m.IsVirtual(),
})
}
@@ -202,8 +204,13 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else {
mc.ModelConfig.SetAPIKey(mc.APIKey)
}
+ // Preserve existing ExtraBody when omitted (nil), but clear it when
+ // the frontend sends an empty object {} to indicate the field should
+ // be removed.
if mc.ExtraBody == nil {
mc.ExtraBody = cfg.ModelList[idx].ExtraBody
+ } else if len(mc.ExtraBody) == 0 {
+ mc.ExtraBody = nil
}
cfg.ModelList[idx] = &mc.ModelConfig
@@ -288,11 +295,13 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
return
}
- // Verify the model_name exists in model_list
+ // Verify the model_name exists in model_list and is not a virtual model
found := false
+ isVirtual := false
for _, m := range cfg.ModelList {
if m.ModelName == req.ModelName {
found = true
+ isVirtual = m.IsVirtual()
break
}
}
@@ -300,6 +309,10 @@ func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request)
http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound)
return
}
+ if isVirtual {
+ http.Error(w, fmt.Sprintf("Cannot set virtual model %q as default", req.ModelName), http.StatusBadRequest)
+ return
+ }
cfg.Agents.Defaults.ModelName = req.ModelName
diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go
index 9d3e72bd3..c80527fe3 100644
--- a/web/backend/api/models_test.go
+++ b/web/backend/api/models_test.go
@@ -356,6 +356,46 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
}
}
+// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
+// model as default returns 404. This covers the case where virtual models (which are
+// filtered by SaveConfig) cannot be set as default.
+func TestHandleSetDefaultModel_RejectsNonexistentModel(t *testing.T) {
+ configPath, cleanup := setupOAuthTestEnv(t)
+ defer cleanup()
+
+ // First save a valid config with a primary model
+ cfg, err := config.LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error = %v", err)
+ }
+ cfg.ModelList = []*config.ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4o"},
+ }
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ t.Fatalf("SaveConfig() error = %v", err)
+ }
+
+ // Try to set a non-existent model (like a virtual model name) as default
+ h := NewHandler(configPath)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/models/default", bytes.NewBufferString(`{
+ "model_name": "gpt-4__key_1"
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ mux.ServeHTTP(rec, req)
+
+ // Should return 404 because the virtual model doesn't exist in the persisted config
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "not found") {
+ t.Fatalf("error message should mention 'not found', got: %s", rec.Body.String())
+ }
+}
+
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
diff --git a/web/backend/main.go b/web/backend/main.go
index 2f181603e..6987a4515 100644
--- a/web/backend/main.go
+++ b/web/backend/main.go
@@ -98,7 +98,7 @@ func main() {
defer logger.DisableFileLogging()
}
- logger.InfoC("web", "PicoClaw Launcher starting...")
+ logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion))
logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome))
// Set language from command line or auto-detect
diff --git a/web/backend/systray.go b/web/backend/systray.go
index fde2e115e..9dcc025df 100644
--- a/web/backend/systray.go
+++ b/web/backend/systray.go
@@ -3,7 +3,6 @@
package main
import (
- _ "embed"
"fmt"
"fyne.io/systray"
@@ -93,8 +92,3 @@ func onReady() {
func onExit() {
logger.Info(T(Exiting))
}
-
-// getIcon returns the system tray icon
-func getIcon() []byte {
- return iconData
-}
diff --git a/web/backend/systray_icon_nonwindows.go b/web/backend/systray_icon_nonwindows.go
new file mode 100644
index 000000000..0117a9ae8
--- /dev/null
+++ b/web/backend/systray_icon_nonwindows.go
@@ -0,0 +1,12 @@
+//go:build !windows && ((!darwin && !freebsd) || cgo)
+
+package main
+
+import _ "embed"
+
+//go:embed icon.png
+var iconPNG []byte
+
+func getIcon() []byte {
+ return iconPNG
+}
diff --git a/web/backend/systray_windows.go b/web/backend/systray_icon_windows.go
similarity index 53%
rename from web/backend/systray_windows.go
rename to web/backend/systray_icon_windows.go
index cc1885155..c265e2f9c 100644
--- a/web/backend/systray_windows.go
+++ b/web/backend/systray_icon_windows.go
@@ -5,4 +5,8 @@ package main
import _ "embed"
//go:embed icon.ico
-var iconData []byte
+var iconICO []byte
+
+func getIcon() []byte {
+ return iconICO
+}
diff --git a/web/backend/tray_stub_nocgo.go b/web/backend/systray_stub_nocgo.go
similarity index 88%
rename from web/backend/tray_stub_nocgo.go
rename to web/backend/systray_stub_nocgo.go
index 13ecfd2cb..9e75e112a 100644
--- a/web/backend/tray_stub_nocgo.go
+++ b/web/backend/systray_stub_nocgo.go
@@ -13,6 +13,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
+// runTray falls back to a headless mode on platforms where systray requires cgo.
func runTray() {
logger.Infof("System tray is unavailable in %s builds without cgo; running without tray", runtime.GOOS)
diff --git a/web/backend/systray_unix.go b/web/backend/systray_unix.go
deleted file mode 100644
index 0f9d2bb51..000000000
--- a/web/backend/systray_unix.go
+++ /dev/null
@@ -1,8 +0,0 @@
-//go:build !windows
-
-package main
-
-import _ "embed"
-
-//go:embed icon.png
-var iconData []byte
diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts
index 2fd042593..aa66a7389 100644
--- a/web/frontend/src/api/models.ts
+++ b/web/frontend/src/api/models.ts
@@ -21,6 +21,7 @@ export interface ModelInfo {
// Meta
configured: boolean
is_default: boolean
+ is_virtual: boolean
}
interface ModelsListResponse {
diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx
index ee483d652..7f1f695bc 100644
--- a/web/frontend/src/components/channels/channel-config-page.tsx
+++ b/web/frontend/src/components/channels/channel-config-page.tsx
@@ -145,13 +145,7 @@ function isConfigured(
case "weixin":
return asString(config.account_id) !== ""
case "wecom":
- return asString(config.token) !== ""
- case "wecom_app":
- return (
- asString(config.corp_id) !== "" && asString(config.corp_secret) !== ""
- )
- case "wecom_aibot":
- return asString(config.token) !== ""
+ return asString(config.bot_id) !== ""
case "whatsapp":
return asString(config.bridge_url) !== ""
case "whatsapp_native":
@@ -192,11 +186,7 @@ function getRequiredFieldKeys(channelName: string): string[] {
case "onebot":
return ["ws_url"]
case "wecom":
- return ["token"]
- case "wecom_app":
- return ["corp_id", "corp_secret"]
- case "wecom_aibot":
- return ["token"]
+ return ["bot_id", "secret"]
case "whatsapp":
return ["bridge_url"]
case "pico":
diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx
index db14fc206..1a872542b 100644
--- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx
+++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx
@@ -28,6 +28,7 @@ const SECRET_FIELDS = new Set([
"encoding_aes_key",
"encrypt_key",
"verification_token",
+ "secret",
"password",
"nickserv_password",
"sasl_password",
@@ -44,6 +45,7 @@ const OBJECT_FIELDS = new Set([
"allow_token_query",
"allow_from",
"allow_origins",
+ "groups",
])
function formatLabel(key: string): string {
@@ -118,6 +120,14 @@ export function GenericForm({
app_id: t("channels.form.desc.appId"),
client_id: t("channels.form.desc.clientId"),
corp_id: t("channels.form.desc.corpId"),
+ bot_id: t("channels.form.desc.appId"),
+ websocket_url: t("channels.form.desc.wsUrl"),
+ dm_policy: t("channels.form.desc.genericField", { field: "DM policy" }),
+ group_policy: t("channels.form.desc.genericField", { field: "group policy" }),
+ group_allow_from: t("channels.form.desc.allowFrom"),
+ send_thinking_message: t("channels.form.desc.genericField", {
+ field: "thinking message behavior",
+ }),
agent_id: t("channels.form.desc.agentId"),
webhook_url: t("channels.form.desc.webhookUrl"),
webhook_host: t("channels.form.desc.webhookHost"),
diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx
index 5482b0a35..1f7426d22 100644
--- a/web/frontend/src/components/config/config-sections.tsx
+++ b/web/frontend/src/components/config/config-sections.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
@@ -7,6 +8,7 @@ import {
type LauncherForm,
} from "@/components/config/form-model"
import { Field, SwitchCardField } from "@/components/shared-form"
+import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
@@ -201,6 +203,56 @@ interface ExecSectionProps {
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
const { t } = useTranslation()
+ const [testCommand, setTestCommand] = useState("")
+ const [testResult, setTestResult] = useState<{
+ allowed: boolean
+ blocked: boolean
+ matchedWhitelist: string | null
+ matchedBlacklist: string | null
+ } | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const testPatterns = async () => {
+ if (!testCommand.trim()) {
+ setTestResult(null)
+ return
+ }
+
+ const allowPatterns = form.customAllowPatternsText
+ .split("\n")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0)
+ const denyPatterns = form.enableDenyPatterns
+ ? form.customDenyPatternsText
+ .split("\n")
+ .map((p) => p.trim())
+ .filter((p) => p.length > 0)
+ : []
+
+ setIsLoading(true)
+ try {
+ const res = await fetch("/api/config/test-command-patterns", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ allow_patterns: allowPatterns,
+ deny_patterns: denyPatterns,
+ command: testCommand,
+ }),
+ })
+ const data = await res.json()
+ setTestResult({
+ allowed: data.allowed,
+ blocked: data.blocked,
+ matchedWhitelist: data.matched_whitelist ?? null,
+ matchedBlacklist: data.matched_blacklist ?? null,
+ })
+ } catch {
+ setTestResult(null)
+ } finally {
+ setIsLoading(false)
+ }
+ }
return (
@@ -266,6 +318,50 @@ export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
/>
+
+
+
+ setTestCommand(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ testPatterns()
+ }
+ }}
+ />
+
+
+ {testResult && (
+
+ {testResult.allowed
+ ? `${t("pages.config.pattern_detector_result_allowed")}${testResult.matchedWhitelist ? ` (${testResult.matchedWhitelist})` : ""}`
+ : testResult.blocked
+ ? `${t("pages.config.pattern_detector_result_blocked")}${testResult.matchedBlacklist ? ` (${testResult.matchedBlacklist})` : ""}`
+ : t("pages.config.pattern_detector_result_no_match")}
+
+ )}
+
+
+
(e: React.ChangeEvent) => {
+ (key: keyof AddForm) => (e: React.ChangeEvent) => {
setForm((f) => ({ ...f, [key]: e.target.value }))
if (fieldErrors[key]) {
setFieldErrors((prev) => ({ ...prev, [key]: undefined }))
@@ -129,6 +132,9 @@ export function AddModelSheet({
? Number(form.requestTimeout)
: undefined,
thinking_level: form.thinkingLevel.trim() || undefined,
+ extra_body: form.extraBody.trim()
+ ? JSON.parse(form.extraBody.trim())
+ : undefined,
})
if (setAsDefault) {
await setDefaultModel(modelName)
@@ -305,6 +311,18 @@ export function AddModelSheet({
placeholder="max_completion_tokens"
/>
+
+
+
+
{serverError && (
diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx
index 237991a9f..13678f03d 100644
--- a/web/frontend/src/components/models/edit-model-sheet.tsx
+++ b/web/frontend/src/components/models/edit-model-sheet.tsx
@@ -12,6 +12,7 @@ import {
} from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
import {
Sheet,
SheetContent,
@@ -32,6 +33,7 @@ interface EditForm {
maxTokensField: string
requestTimeout: string
thinkingLevel: string
+ extraBody: string
}
interface EditModelSheetProps {
@@ -59,6 +61,7 @@ export function EditModelSheet({
maxTokensField: "",
requestTimeout: "",
thinkingLevel: "",
+ extraBody: "",
})
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
@@ -79,6 +82,9 @@ export function EditModelSheet({
? String(model.request_timeout)
: "",
thinkingLevel: model.thinking_level ?? "",
+ extraBody: model.extra_body
+ ? JSON.stringify(model.extra_body, null, 2)
+ : "",
})
setSetAsDefault(model.is_default)
setError("")
@@ -86,7 +92,7 @@ export function EditModelSheet({
}, [model])
const setField =
- (key: keyof EditForm) => (e: React.ChangeEvent) =>
+ (key: keyof EditForm) => (e: React.ChangeEvent) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
const handleSave = async () => {
@@ -109,6 +115,9 @@ export function EditModelSheet({
? Number(form.requestTimeout)
: undefined,
thinking_level: form.thinkingLevel || undefined,
+ extra_body: form.extraBody.trim()
+ ? JSON.parse(form.extraBody.trim())
+ : {},
})
if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name)
@@ -273,6 +282,18 @@ export function EditModelSheet({
placeholder="max_completion_tokens"
/>
+
+
+
+
{error && (
diff --git a/web/frontend/src/components/models/model-card.tsx b/web/frontend/src/components/models/model-card.tsx
index 316e05e4d..319cb11a3 100644
--- a/web/frontend/src/components/models/model-card.tsx
+++ b/web/frontend/src/components/models/model-card.tsx
@@ -28,7 +28,7 @@ export function ModelCard({
}: ModelCardProps) {
const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth"
- const canSetDefault = model.configured && !model.is_default
+ const canSetDefault = model.configured && !model.is_default && !model.is_virtual
return (
)}
+ {model.is_virtual && (
+
+ {t("models.badge.virtual")}
+
+ )}
diff --git a/web/frontend/src/hooks/use-sidebar-channels.ts b/web/frontend/src/hooks/use-sidebar-channels.ts
index 95634154f..22fc24e57 100644
--- a/web/frontend/src/hooks/use-sidebar-channels.ts
+++ b/web/frontend/src/hooks/use-sidebar-channels.ts
@@ -32,8 +32,6 @@ const CHANNEL_IMPORTANCE_TAIL = [
"slack",
"line",
"wecom",
- "wecom_app",
- "wecom_aibot",
"dingtalk",
"qq",
"onebot",
@@ -78,8 +76,6 @@ const CHANNEL_ICON_MAP: Record<
qq: IconBrandQq,
weixin: IconBrandWechat,
wecom: IconBrandWechat,
- wecom_app: IconBrandWechat,
- wecom_aibot: IconBrandWechat,
whatsapp: IconBrandWhatsapp,
whatsapp_native: IconBrandWhatsapp,
matrix: IconBrandMatrix,
diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json
index 96d85e88c..d15cde693 100644
--- a/web/frontend/src/i18n/locales/en.json
+++ b/web/frontend/src/i18n/locales/en.json
@@ -154,7 +154,8 @@
"unconfigured": "Not configured"
},
"badge": {
- "default": "Default"
+ "default": "Default",
+ "virtual": "Virtual"
},
"action": {
"edit": "Edit API key",
@@ -208,7 +209,9 @@
"thinkingLevel": "Thinking Level",
"thinkingLevelHint": "Extended thinking budget: off, low, medium, high, xhigh, adaptive.",
"maxTokensField": "Max Tokens Field",
- "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens."
+ "maxTokensFieldHint": "Override the request field name for max tokens, e.g. max_completion_tokens.",
+ "extraBody": "Extra Body",
+ "extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}."
},
"edit": {
"title": "Configure {{name}}",
@@ -233,8 +236,6 @@
"qq": "QQ",
"onebot": "OneBot",
"wecom": "WeCom",
- "wecom_app": "WeCom App",
- "wecom_aibot": "WeCom AI Bot",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",
@@ -434,6 +435,13 @@
"custom_allow_patterns": "Command Whitelist",
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "Pattern Detection Tool",
+ "pattern_detector_hint": "Enter a command to test if it matches any blacklist or whitelist patterns.",
+ "pattern_detector_input_placeholder": "Enter a command to test, e.g., rm -rf /tmp",
+ "pattern_detector_test_button": "Test",
+ "pattern_detector_result_allowed": "Allowed (matches whitelist)",
+ "pattern_detector_result_blocked": "Blocked (matches blacklist)",
+ "pattern_detector_result_no_match": "No match (will use default rules)",
"allow_shell_execution": "Allow Scheduled Commands",
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json
index d7d0ffc4c..7eb14e983 100644
--- a/web/frontend/src/i18n/locales/zh.json
+++ b/web/frontend/src/i18n/locales/zh.json
@@ -154,7 +154,8 @@
"unconfigured": "未配置"
},
"badge": {
- "default": "默认"
+ "default": "默认",
+ "virtual": "虚拟"
},
"action": {
"edit": "编辑 API Key",
@@ -208,7 +209,9 @@
"thinkingLevel": "思考级别",
"thinkingLevelHint": "扩展思考预算:off、low、medium、high、xhigh、adaptive。",
"maxTokensField": "Max Tokens 字段名",
- "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。"
+ "maxTokensFieldHint": "覆盖请求中 max_tokens 的字段名,例如 max_completion_tokens。",
+ "extraBody": "Extra Body",
+ "extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。"
},
"edit": {
"title": "配置 {{name}}",
@@ -233,8 +236,6 @@
"qq": "QQ",
"onebot": "OneBot",
"wecom": "企业微信",
- "wecom_app": "企业微信应用",
- "wecom_aibot": "企业微信 AI 机器人",
"whatsapp": "WhatsApp",
"whatsapp_native": "WhatsApp Native",
"pico": "Web",
@@ -434,6 +435,13 @@
"custom_allow_patterns": "命令白名单",
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
+ "pattern_detector_title": "规则检测工具",
+ "pattern_detector_hint": "输入命令以检测其是否匹配黑名单或白名单规则。",
+ "pattern_detector_input_placeholder": "输入要检测的命令,例如 rm -rf /tmp",
+ "pattern_detector_test_button": "检测",
+ "pattern_detector_result_allowed": "允许(匹配白名单)",
+ "pattern_detector_result_blocked": "阻止(匹配黑名单)",
+ "pattern_detector_result_no_match": "无匹配(将使用默认规则)",
"allow_shell_execution": "允许定时任务运行命令",
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
"cron_exec_timeout": "定时命令超时(分钟)",