package config import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync/atomic" "github.com/caarlos0/env/v11" "github.com/sipeed/picoclaw/pkg" "github.com/sipeed/picoclaw/pkg/credential" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" ) // rrCounter is a global counter for round-robin load balancing across models. var rrCounter atomic.Uint64 // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. // It also supports parsing comma-separated strings from environment variables, // including both English (,) and Chinese (,) commas. type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { // Try []string first var ss []string if err := json.Unmarshal(data, &ss); err == nil { *f = ss return nil } // Try []interface{} to handle mixed types var raw []any if err := json.Unmarshal(data, &raw); err != nil { return err } result := make([]string, 0, len(raw)) for _, v := range raw { switch val := v.(type) { case string: result = append(result, val) case float64: result = append(result, fmt.Sprintf("%.0f", val)) default: result = append(result, fmt.Sprintf("%v", val)) } } *f = result return nil } // UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. // It handles comma-separated values with both English (,) and Chinese (,) commas. func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { if len(text) == 0 { *f = nil return nil } s := string(text) // Replace Chinese comma with English comma, then split s = strings.ReplaceAll(s, ",", ",") parts := strings.Split(s, ",") result := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { result = append(result, part) } } *f = result return nil } // CurrentVersion is the latest config schema version const CurrentVersion = 1 // Config is the current config structure with version support type Config struct { Version int `json:"version"` // Config schema version for migration Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` Session SessionConfig `json:"session,omitempty"` Channels ChannelsConfig `json:"channels"` ModelList []*ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` Hooks HooksConfig `json:"hooks,omitempty"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` Voice VoiceConfig `json:"voice"` // BuildInfo contains build-time version information BuildInfo BuildInfo `json:"build_info,omitempty"` security *SecurityConfig } func (c *Config) WithSecurity(sec *SecurityConfig) *Config { if sec == nil { c.security = sec return c } err := applySecurityConfig(c, sec) if err != nil { return nil } c.security = sec return c } // FilterSensitiveData filters sensitive values from content before sending to LLM. // This prevents the LLM from seeing its own credentials. // Uses strings.Replacer for O(n+m) performance (computed once per SecurityConfig). // Short content (below FilterMinLength) is returned unchanged for performance. func (c *Config) FilterSensitiveData(content string) string { if c.security == nil || content == "" { return content } // Check if filtering is enabled (default: true) if !c.Tools.IsFilterSensitiveDataEnabled() { return content } // Fast path: skip filtering for short content if len(content) < c.Tools.GetFilterMinLength() { return content } return c.security.SensitiveDataReplacer().Replace(content) } type HooksConfig struct { Enabled bool `json:"enabled"` Defaults HookDefaultsConfig `json:"defaults,omitempty"` Builtins map[string]BuiltinHookConfig `json:"builtins,omitempty"` Processes map[string]ProcessHookConfig `json:"processes,omitempty"` } type HookDefaultsConfig struct { ObserverTimeoutMS int `json:"observer_timeout_ms,omitempty"` InterceptorTimeoutMS int `json:"interceptor_timeout_ms,omitempty"` ApprovalTimeoutMS int `json:"approval_timeout_ms,omitempty"` } type BuiltinHookConfig struct { Enabled bool `json:"enabled"` Priority int `json:"priority,omitempty"` Config json.RawMessage `json:"config,omitempty"` } type ProcessHookConfig struct { Enabled bool `json:"enabled"` Priority int `json:"priority,omitempty"` Transport string `json:"transport,omitempty"` Command []string `json:"command,omitempty"` Dir string `json:"dir,omitempty"` Env map[string]string `json:"env,omitempty"` Observe []string `json:"observe,omitempty"` Intercept []string `json:"intercept,omitempty"` } // BuildInfo contains build-time version information type BuildInfo struct { Version string `json:"version"` GitCommit string `json:"git_commit"` BuildTime string `json:"build_time"` GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config // to omit providers section when empty and session when empty func (c *Config) MarshalJSON() ([]byte, error) { type Alias Config aux := &struct { Session *SessionConfig `json:"session,omitempty"` *Alias }{ Alias: (*Alias)(c), } // Only include session if not empty if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 { aux.Session = &c.Session } return json.Marshal(aux) } type AgentsConfig struct { Defaults AgentDefaults `json:"defaults"` List []AgentConfig `json:"list,omitempty"` } // AgentModelConfig supports both string and structured model config. // String format: "gpt-4" (just primary, no fallbacks) // Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]} type AgentModelConfig struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } func (m *AgentModelConfig) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err == nil { m.Primary = s m.Fallbacks = nil return nil } type raw struct { Primary string `json:"primary"` Fallbacks []string `json:"fallbacks"` } var r raw if err := json.Unmarshal(data, &r); err != nil { return err } m.Primary = r.Primary m.Fallbacks = r.Fallbacks return nil } func (m AgentModelConfig) MarshalJSON() ([]byte, error) { if len(m.Fallbacks) == 0 && m.Primary != "" { return json.Marshal(m.Primary) } type raw struct { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) } type AgentConfig struct { ID string `json:"id"` Default bool `json:"default,omitempty"` Name string `json:"name,omitempty"` Workspace string `json:"workspace,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` Skills []string `json:"skills,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"` } type SubagentsConfig struct { AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } type PeerMatch struct { Kind string `json:"kind"` ID string `json:"id"` } type BindingMatch struct { Channel string `json:"channel"` AccountID string `json:"account_id,omitempty"` Peer *PeerMatch `json:"peer,omitempty"` GuildID string `json:"guild_id,omitempty"` TeamID string `json:"team_id,omitempty"` } type AgentBinding struct { AgentID string `json:"agent_id"` Match BindingMatch `json:"match"` } type SessionConfig struct { DMScope string `json:"dm_scope,omitempty"` IdentityLinks map[string][]string `json:"identity_links,omitempty"` } // RoutingConfig controls the intelligent model routing feature. // When enabled, each incoming message is scored against structural features // (message length, code blocks, tool call history, conversation depth, attachments). // Messages scoring below Threshold are sent to LightModel; all others use the // agent's primary model. This reduces cost and latency for simple tasks without // requiring any keyword matching — all scoring is language-agnostic. type RoutingConfig struct { Enabled bool `json:"enabled"` LightModel string `json:"light_model"` // model_name from model_list to use for simple tasks Threshold float64 `json:"threshold"` // complexity score in [0,1]; score >= threshold → primary model } // SubTurnConfig configures the SubTurn execution system. type SubTurnConfig struct { MaxDepth int `json:"max_depth" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_DEPTH"` MaxConcurrent int `json:"max_concurrent" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_MAX_CONCURRENT"` DefaultTimeoutMinutes int `json:"default_timeout_minutes" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TIMEOUT_MINUTES"` DefaultTokenBudget int `json:"default_token_budget" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_DEFAULT_TOKEN_BUDGET"` ConcurrencyTimeoutSec int `json:"concurrency_timeout_sec" env:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_CONCURRENCY_TIMEOUT_SEC"` } type ToolFeedbackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_ENABLED"` MaxArgsLength int `json:"max_args_length" env:"PICOCLAW_AGENTS_DEFAULTS_TOOL_FEEDBACK_MAX_ARGS_LENGTH"` } type AgentDefaults struct { Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` } const ( DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB DefaultWeComAIBotProcessingMessage = "⏳ Processing, please wait. The results will be sent shortly." ) func (d *AgentDefaults) GetMaxMediaSize() int { if d.MaxMediaSize > 0 { return d.MaxMediaSize } return DefaultMaxMediaSize } // GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength } return 300 } // IsToolFeedbackEnabled returns true when tool feedback messages should be sent to the chat. func (d *AgentDefaults) IsToolFeedbackEnabled() bool { return d.ToolFeedback.Enabled } // GetModelName returns the effective model name for the agent defaults. // It prefers the new "model_name" field but falls back to "model" for backward compatibility. func (d *AgentDefaults) GetModelName() string { return d.ModelName } type ChannelsConfig struct { WhatsApp WhatsAppConfig `json:"whatsapp"` Telegram TelegramConfig `json:"telegram"` Feishu FeishuConfig `json:"feishu"` Discord DiscordConfig `json:"discord"` MaixCam MaixCamConfig `json:"maixcam"` QQ QQConfig `json:"qq"` DingTalk DingTalkConfig `json:"dingtalk"` Slack SlackConfig `json:"slack"` Matrix MatrixConfig `json:"matrix"` LINE LINEConfig `json:"line"` OneBot OneBotConfig `json:"onebot"` WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` WeComAIBot WeComAIBotConfig `json:"wecom_aibot"` Weixin WeixinConfig `json:"weixin"` Pico PicoConfig `json:"pico"` PicoClient PicoClientConfig `json:"pico_client"` IRC IRCConfig `json:"irc"` } // GroupTriggerConfig controls when the bot responds in group chats. type GroupTriggerConfig struct { MentionOnly bool `json:"mention_only,omitempty"` Prefixes []string `json:"prefixes,omitempty"` } // TypingConfig controls typing indicator behavior (Phase 10). type TypingConfig struct { Enabled bool `json:"enabled,omitempty"` } // PlaceholderConfig controls placeholder message behavior (Phase 10). type PlaceholderConfig struct { Enabled bool `json:"enabled,omitempty"` Text string `json:"text,omitempty"` } type StreamingConfig struct { Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"` ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"` MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"` } type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } type TelegramConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` token string BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Streaming StreamingConfig `json:"streaming,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"` secDirty bool } // Token returns the Telegram bot token func (c *TelegramConfig) Token() string { return c.token } // SetToken sets the Telegram bot token func (c *TelegramConfig) SetToken(token string) { c.token = token c.secDirty = true } type FeishuConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` appSecret string encryptKey string verificationToken string AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"` IsLark bool `json:"is_lark" env:"PICOCLAW_CHANNELS_FEISHU_IS_LARK"` secDirty bool } // AppSecret returns the Feishu app secret func (c *FeishuConfig) AppSecret() string { return c.appSecret } // SetAppSecret sets the Feishu app secret func (c *FeishuConfig) SetAppSecret(secret string) { c.appSecret = secret c.secDirty = true } // EncryptKey returns the Feishu encrypt key func (c *FeishuConfig) EncryptKey() string { return c.encryptKey } // SetEncryptKey sets the Feishu encrypt key func (c *FeishuConfig) SetEncryptKey(key string) { c.encryptKey = key c.secDirty = true } // VerificationToken returns the Feishu verification token func (c *FeishuConfig) VerificationToken() string { return c.verificationToken } // SetVerificationToken sets the Feishu verification token func (c *FeishuConfig) SetVerificationToken(token string) { c.verificationToken = token c.secDirty = true } type DiscordConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` token string Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_DISCORD_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` secDirty bool } // Token returns the Discord bot token func (c *DiscordConfig) Token() string { return c.token } // SetToken sets the Discord bot token func (c *DiscordConfig) SetToken(token string) { c.token = token c.secDirty = true } type MaixCamConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"` } type QQConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` appSecret string AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` MaxBase64FileSizeMiB int64 `json:"max_base64_file_size_mib" env:"PICOCLAW_CHANNELS_QQ_MAX_BASE64_FILE_SIZE_MIB"` SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` secDirty bool } // AppSecret returns the QQ app secret func (c *QQConfig) AppSecret() string { return c.appSecret } // SetAppSecret sets the QQ app secret func (c *QQConfig) SetAppSecret(secret string) { c.appSecret = secret c.secDirty = true } type DingTalkConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` clientSecret string AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` secDirty bool } // ClientSecret returns the DingTalk client secret func (c *DingTalkConfig) ClientSecret() string { return c.clientSecret } // SetClientSecret sets the DingTalk client secret func (c *DingTalkConfig) SetClientSecret(secret string) { c.clientSecret = secret c.secDirty = true } type SlackConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` botToken string appToken string AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` secDirty bool } // BotToken returns the Slack bot token func (c *SlackConfig) BotToken() string { return c.botToken } // SetBotToken sets the Slack bot token func (c *SlackConfig) SetBotToken(token string) { c.botToken = token c.secDirty = true } // AppToken returns the Slack app token func (c *SlackConfig) AppToken() string { return c.appToken } // SetAppToken sets the Slack app token func (c *SlackConfig) SetAppToken(token string) { c.appToken = token c.secDirty = true } type MatrixConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` accessToken string DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` secDirty bool } // AccessToken returns the Matrix access token func (c *MatrixConfig) AccessToken() string { return c.accessToken } // SetAccessToken sets the Matrix access token func (c *MatrixConfig) SetAccessToken(token string) { c.accessToken = token c.secDirty = true } type LINEConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` channelSecret string channelAccessToken string WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` secDirty bool } // ChannelSecret returns the LINE channel secret func (c *LINEConfig) ChannelSecret() string { return c.channelSecret } // SetChannelSecret sets the LINE channel secret func (c *LINEConfig) SetChannelSecret(secret string) { c.channelSecret = secret c.secDirty = true } // ChannelAccessToken returns the LINE channel access token func (c *LINEConfig) ChannelAccessToken() string { return c.channelAccessToken } // SetChannelAccessToken sets the LINE channel access token func (c *LINEConfig) SetChannelAccessToken(token string) { c.channelAccessToken = token c.secDirty = true } type OneBotConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` accessToken string ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` secDirty bool } // AccessToken returns the OneBot access token func (c *OneBotConfig) AccessToken() string { return c.accessToken } // SetAccessToken sets the OneBot access token func (c *OneBotConfig) SetAccessToken(token string) { c.accessToken = token c.secDirty = true } 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 } // 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 { return c.secret } func (c *WeComAIBotConfig) SetSecret(secret string) { c.secret = secret c.secDirty = true } type WeixinConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WEIXIN_ENABLED"` token string BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_WEIXIN_BASE_URL"` CDNBaseURL string `json:"cdn_base_url" env:"PICOCLAW_CHANNELS_WEIXIN_CDN_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_WEIXIN_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WEIXIN_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WEIXIN_REASONING_CHANNEL_ID"` secDirty bool } func (c *WeixinConfig) Token() string { return c.token } func (c *WeixinConfig) SetToken(token string) *WeixinConfig { c.token = token c.secDirty = true return c } type PicoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` token string AllowTokenQuery bool `json:"allow_token_query,omitempty"` AllowOrigins []string `json:"allow_origins,omitempty"` PingInterval int `json:"ping_interval,omitempty"` ReadTimeout int `json:"read_timeout,omitempty"` WriteTimeout int `json:"write_timeout,omitempty"` MaxConnections int `json:"max_connections,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` secDirty bool } // Token returns the Pico channel token func (c *PicoConfig) Token() string { return c.token } // SetToken sets the Pico channel token func (c *PicoConfig) SetToken(token string) { c.token = token c.secDirty = true } type PicoClientConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"` URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"` Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"` SessionID string `json:"session_id,omitempty"` PingInterval int `json:"ping_interval,omitempty"` ReadTimeout int `json:"read_timeout,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"` } type IRCConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"` Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"` TLS bool `json:"tls" env:"PICOCLAW_CHANNELS_IRC_TLS"` Nick string `json:"nick" env:"PICOCLAW_CHANNELS_IRC_NICK"` User string `json:"user,omitempty" env:"PICOCLAW_CHANNELS_IRC_USER"` RealName string `json:"real_name,omitempty" env:"PICOCLAW_CHANNELS_IRC_REAL_NAME"` password string nickServPassword string SASLUser string `json:"sasl_user" env:"PICOCLAW_CHANNELS_IRC_SASL_USER"` saslPassword string Channels FlexibleStringSlice `json:"channels" env:"PICOCLAW_CHANNELS_IRC_CHANNELS"` RequestCaps FlexibleStringSlice `json:"request_caps,omitempty" env:"PICOCLAW_CHANNELS_IRC_REQUEST_CAPS"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_IRC_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_IRC_REASONING_CHANNEL_ID"` secDirty bool } // Password returns the IRC password func (c *IRCConfig) Password() string { return c.password } // NickServPassword returns the NickServ password func (c *IRCConfig) NickServPassword() string { return c.nickServPassword } // SASLPassword returns the SASL password func (c *IRCConfig) SASLPassword() string { return c.saslPassword } func (c *IRCConfig) SetPassword(password string) { c.password = password c.secDirty = true } func (c *IRCConfig) SetNickServPassword(password string) { c.nickServPassword = password c.secDirty = true } func (c *IRCConfig) SetSASLPassword(password string) { c.saslPassword = password c.secDirty = true } type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 } type DevicesConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"` MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } type VoiceConfig struct { ModelName string `json:"model_name,omitempty" env:"PICOCLAW_VOICE_MODEL_NAME"` EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` } // ModelConfig represents a model-centric provider configuration. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // The model field uses protocol prefix format: [protocol/]model-identifier // Supported protocols include openai, anthropic, antigravity, claude-cli, // codex-cli, github-copilot, and named OpenAI-compatible protocols such as // groq, deepseek, modelscope, and novita. // Default protocol is "openai" if no prefix is specified. type ModelConfig struct { // Required fields ModelName string `json:"model_name"` // User-facing alias for the model Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6") // HTTP-based providers APIBase string `json:"api_base,omitempty"` // API endpoint URL Proxy string `json:"proxy,omitempty"` // HTTP proxy URL Fallbacks []string `json:"fallbacks,omitempty"` // Fallback model names for failover // Special providers (CLI-based, OAuth, etc.) AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive ExtraBody map[string]any `json:"extra_body,omitempty"` // Additional fields to inject into request body // from security secModelName string apiKeys []string secDirty bool } // APIKey returns the first API key from apiKeys func (c *ModelConfig) APIKey() string { if len(c.apiKeys) > 0 { return c.apiKeys[0] } return "" } // Validate checks if the ModelConfig has all required fields. func (c *ModelConfig) Validate() error { if c.ModelName == "" { return fmt.Errorf("model_name is required") } if c.Model == "" { return fmt.Errorf("model is required") } return nil } func (c *ModelConfig) SetAPIKey(value string) { if len(c.apiKeys) > 0 { c.apiKeys[0] = value } else { c.apiKeys = append(c.apiKeys, value) } c.secDirty = true } type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` HotReload bool `json:"hot_reload" env:"PICOCLAW_GATEWAY_HOT_RELOAD"` LogLevel string `json:"log_level,omitempty" env:"PICOCLAW_LOG_LEVEL"` } type ToolDiscoveryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` } type ToolConfig struct { Enabled bool `json:"enabled" env:"ENABLED"` } type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` apiKeys []string secDirty bool MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } // APIKey returns the Brave API key func (c *BraveConfig) APIKey() string { if len(c.apiKeys) == 0 { return "" } return c.apiKeys[0] } // APIKeys returns the Brave API keys func (c *BraveConfig) APIKeys() []string { return c.apiKeys } // SetAPIKey sets the Brave API key func (c *BraveConfig) SetAPIKey(key string) { c.apiKeys = []string{key} c.secDirty = true } // SetAPIKeys sets the Brave API keys func (c *BraveConfig) SetAPIKeys(keys []string) { c.apiKeys = keys c.secDirty = true } type TavilyConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` apiKeys []string secDirty bool BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } // APIKey returns the Tavily API key func (c *TavilyConfig) APIKey() string { if len(c.apiKeys) == 0 { return "" } return c.apiKeys[0] } // APIKeys returns the Tavily API keys func (c *TavilyConfig) APIKeys() []string { return c.apiKeys } // SetAPIKey sets the Tavily API key func (c *TavilyConfig) SetAPIKey(key string) { c.apiKeys = []string{key} c.secDirty = true } // SetAPIKeys sets the Tavily API keys func (c *TavilyConfig) SetAPIKeys(keys []string) { c.apiKeys = keys c.secDirty = true } type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } type PerplexityConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` apiKeys []string secDirty bool MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } // APIKey returns the Perplexity API key func (c *PerplexityConfig) APIKey() string { if len(c.apiKeys) == 0 { return "" } return c.apiKeys[0] } // SetAPIKey sets the Perplexity API key func (c *PerplexityConfig) SetAPIKey(key string) { c.apiKeys = []string{key} c.secDirty = true } // APIKeys returns the Perplexity API keys func (c *PerplexityConfig) APIKeys() []string { return c.apiKeys } // SetAPIKeys sets the Perplexity API keys func (c *PerplexityConfig) SetAPIKeys(keys []string) { c.apiKeys = keys c.secDirty = true } type SearXNGConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_SEARXNG_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_SEARXNG_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARXNG_MAX_RESULTS"` } type GLMSearchConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"` apiKey string secDirty bool BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"` // SearchEngine specifies the search backend: "search_std" (default), // "search_pro", "search_pro_sogou", or "search_pro_quark". SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"` } // APIKey returns the GLM search API key func (c *GLMSearchConfig) APIKey() string { return c.apiKey } // SetAPIKey sets the GLM search API key (internal use only) func (c *GLMSearchConfig) SetAPIKey(key string) { c.apiKey = key c.secDirty = true } type BaiduSearchConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BAIDU_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_BAIDU_BASE_URL"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BAIDU_MAX_RESULTS"` apiKey string secDirty bool } // APIKey returns the Baidu search API key func (c *BaiduSearchConfig) APIKey() string { return c.apiKey } func (c *BaiduSearchConfig) SetAPIKey(key string) { c.apiKey = key c.secDirty = true } type WebToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig ` json:"brave"` Tavily TavilyConfig ` json:"tavily"` DuckDuckGo DuckDuckGoConfig ` json:"duckduckgo"` Perplexity PerplexityConfig ` json:"perplexity"` SearXNG SearXNGConfig ` json:"searxng"` GLMSearch GLMSearchConfig ` json:"glm_search"` BaiduSearch BaiduSearchConfig ` json:"baidu_search"` // PreferNative controls whether to use provider-native web search when // the active LLM supports it (e.g. OpenAI web_search_preview). When true, // the client-side web_search tool is hidden to avoid duplicate search surfaces, // and the provider's built-in search is used instead. Falls back to client-side // search when the provider does not support native search. PreferNative bool `json:"prefer_native" env:"PICOCLAW_TOOLS_WEB_PREFER_NATIVE"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` Format string `json:"format,omitempty" env:"PICOCLAW_TOOLS_WEB_FORMAT"` PrivateHostWhitelist FlexibleStringSlice `json:"private_host_whitelist,omitempty" env:"PICOCLAW_TOOLS_WEB_PRIVATE_HOST_WHITELIST"` } type CronToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"` ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"` } type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` Github SkillsGithubConfig ` json:"github"` MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` SearchCache SearchCacheConfig ` json:"search_cache"` } type MediaCleanupConfig struct { ToolConfig ` envPrefix:"PICOCLAW_MEDIA_CLEANUP_"` MaxAge int ` env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE" json:"max_age_minutes"` Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } type ReadFileToolConfig struct { Enabled bool `json:"enabled"` MaxReadFileSize int `json:"max_read_file_size"` } type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` // FilterSensitiveData controls whether to filter sensitive values (API keys, // tokens, secrets) from tool results before sending to the LLM. // Default: true (enabled) FilterSensitiveData bool `json:"filter_sensitive_data" env:"PICOCLAW_TOOLS_FILTER_SENSITIVE_DATA"` // FilterMinLength is the minimum content length required for filtering. // Content shorter than this will be returned unchanged for performance. // Default: 8 FilterMinLength int `json:"filter_min_length" env:"PICOCLAW_TOOLS_FILTER_MIN_LENGTH"` Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` Exec ExecConfig `json:"exec"` Skills SkillsToolsConfig `json:"skills"` MediaCleanup MediaCleanupConfig `json:"media_cleanup"` MCP MCPConfig `json:"mcp"` AppendFile ToolConfig `json:"append_file" envPrefix:"PICOCLAW_TOOLS_APPEND_FILE_"` EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"` FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"` I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"` } // IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled func (c *ToolsConfig) IsFilterSensitiveDataEnabled() bool { return c.FilterSensitiveData } // GetFilterMinLength returns the minimum content length for filtering (default: 8) func (c *ToolsConfig) GetFilterMinLength() int { if c.FilterMinLength <= 0 { return 8 } return c.FilterMinLength } type SearchCacheConfig struct { MaxSize int `json:"max_size" env:"PICOCLAW_SKILLS_SEARCH_CACHE_MAX_SIZE"` TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"` } type SkillsRegistriesConfig struct { ClawHub ClawHubRegistryConfig `json:"clawhub"` } type SkillsGithubConfig struct { token string secDirty bool Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"` } // Token returns the GitHub token func (c *SkillsGithubConfig) Token() string { return c.token } // SetToken sets the GitHub token func (c *SkillsGithubConfig) SetToken(token string) { c.token = token c.secDirty = true } type ClawHubRegistryConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` authToken string secDirty bool SearchPath string `json:"search_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SEARCH_PATH"` SkillsPath string `json:"skills_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_SKILLS_PATH"` DownloadPath string `json:"download_path" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_DOWNLOAD_PATH"` Timeout int `json:"timeout" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_TIMEOUT"` MaxZipSize int `json:"max_zip_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_ZIP_SIZE"` MaxResponseSize int `json:"max_response_size" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_MAX_RESPONSE_SIZE"` } // AuthToken returns the ClawHub auth token func (c *ClawHubRegistryConfig) AuthToken() string { return c.authToken } // SetAuthToken sets the ClawHub auth token func (c *ClawHubRegistryConfig) SetAuthToken(token string) { c.authToken = token c.secDirty = true } // MCPServerConfig defines configuration for a single MCP server type MCPServerConfig struct { // Enabled indicates whether this MCP server is active Enabled bool `json:"enabled"` // Deferred controls whether this server's tools are registered as hidden (deferred/discovery mode). // When nil, the global Discovery.Enabled setting applies. // When explicitly set to true or false, it overrides the global setting for this server only. Deferred *bool `json:"deferred,omitempty"` // Command is the executable to run (e.g., "npx", "python", "/path/to/server") Command string `json:"command"` // Args are the arguments to pass to the command Args []string `json:"args,omitempty"` // Env are environment variables to set for the server process (stdio only) Env map[string]string `json:"env,omitempty"` // EnvFile is the path to a file containing environment variables (stdio only) EnvFile string `json:"env_file,omitempty"` // Type is "stdio", "sse", or "http" (default: stdio if command is set, sse if url is set) Type string `json:"type,omitempty"` // URL is used for SSE/HTTP transport URL string `json:"url,omitempty"` // Headers are HTTP headers to send with requests (sse/http only) Headers map[string]string `json:"headers,omitempty"` } // MCPConfig defines configuration for all MCP servers type MCPConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } func LoadConfig(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return DefaultConfig(), nil } return nil, err } // First, try to detect config version by reading the version field var versionInfo struct { Version int `json:"version"` } if e := json.Unmarshal(data, &versionInfo); e != nil { return nil, fmt.Errorf("failed to detect config version: %w", e) } if len(data) <= 10 { return DefaultConfig().WithSecurity(&SecurityConfig{}), nil } // Load config based on detected version var cfg *Config switch versionInfo.Version { case 0: logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) // Legacy config (no version field) v, e := loadConfigV0(data) if e != nil { return nil, e } cfg, e = v.Migrate() if e != nil { logger.DebugF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) return nil, e } logger.DebugF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion}) defer func() { _ = SaveConfig(path, cfg) }() case CurrentVersion: // Current version cfg, err = loadConfig(data) if err != nil { return nil, err } default: return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version) } // Load security configuration securityPath := securityPath(path) sec, err := loadSecurityConfig(securityPath) if err != nil { return nil, fmt.Errorf("failed to load security config: %w", err) } // Apply security references from .security.yml BEFORE resolveAPIKeys // This resolves ref: references to actual values if err := applySecurityConfig(cfg, sec); err != nil { return nil, fmt.Errorf("failed to apply security config: %w", err) } if passphrase := credential.PassphraseProvider(); passphrase != "" { for _, m := range cfg.ModelList { for _, k := range m.apiKeys { if k != "" && !strings.HasPrefix(k, "enc://") && !strings.HasPrefix(k, "file://") { fmt.Fprintf(os.Stderr, "picoclaw: warning: model %q has a plaintext api_key; call SaveConfig to encrypt it\n", m.ModelName) break // Only warn once per model } } } } if err := env.Parse(cfg); err != nil { return nil, err } if err := resolveAPIKeys(cfg.ModelList, filepath.Dir(path)); err != nil { return nil, err } // Resolve security fields like authToken that may contain file:// references if err := resolveSecurityFields(cfg, filepath.Dir(path)); err != nil { return nil, err } // Expand multi-key configs into separate entries for key-level failover cfg.ModelList = expandMultiKeyModels(cfg.ModelList) // Migrate legacy channel config fields to new unified structures cfg.migrateChannelConfigs() // Validate model_list for uniqueness and required fields if err := cfg.ValidateModelList(); err != nil { return nil, err } // Ensure Workspace has a default if not set if cfg.Agents.Defaults.Workspace == "" { homePath, _ := os.UserHomeDir() if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" { homePath = picoclawHome } else if homePath != "" { homePath = filepath.Join(homePath, pkg.DefaultPicoClawHome) } cfg.Agents.Defaults.Workspace = filepath.Join(homePath, pkg.WorkspaceName) } return cfg, nil } func copyArray[T any](dst, src *[]T) { *dst = make([]T, len(*src)) copy(*dst, *src) } // applySecurityConfig resolves all security references in config // It checks each field for "ref:" prefixed values and resolves them from .security.yml func applySecurityConfig(cfg *Config, sec *SecurityConfig) error { if sec == nil { return nil } if sec.Web.Brave != nil && len(sec.Web.Brave.APIKeys) > 0 { copyArray(&cfg.Tools.Web.Brave.apiKeys, &sec.Web.Brave.APIKeys) } if sec.Web.Tavily != nil && len(sec.Web.Tavily.APIKeys) > 0 { copyArray(&cfg.Tools.Web.Tavily.apiKeys, &sec.Web.Tavily.APIKeys) } if sec.Web.Perplexity != nil && len(sec.Web.Perplexity.APIKeys) > 0 { copyArray(&cfg.Tools.Web.Perplexity.apiKeys, &sec.Web.Perplexity.APIKeys) } if sec.Web.GLMSearch != nil && sec.Web.GLMSearch.APIKey != "" { cfg.Tools.Web.GLMSearch.apiKey = sec.Web.GLMSearch.APIKey } if sec.Web.BaiduSearch != nil && sec.Web.BaiduSearch.APIKey != "" { cfg.Tools.Web.BaiduSearch.apiKey = sec.Web.BaiduSearch.APIKey } if sec.Skills.Github != nil && sec.Skills.Github.Token != "" { cfg.Tools.Skills.Github.token = sec.Skills.Github.Token } if sec.Skills.ClawHub != nil && sec.Skills.ClawHub.AuthToken != "" { cfg.Tools.Skills.Registries.ClawHub.authToken = sec.Skills.ClawHub.AuthToken } names := toNameIndex(cfg.ModelList) for i, model := range cfg.ModelList { // Try exact match first (e.g., "abc:0" -> "abc:0") if entry, exists := sec.ModelList[names[i]]; exists { copyArray(&model.apiKeys, &entry.APIKeys) model.secModelName = names[i] continue } // Try match without index suffix (e.g., "abc" -> "abc") // This allows .security.yml to use simpler keys like "test-model" instead of "test-model:0" baseName := model.ModelName if entry, exists := sec.ModelList[baseName]; exists { copyArray(&model.apiKeys, &entry.APIKeys) model.secModelName = baseName continue } } // Handle Telegram token if sec.Channels.Telegram != nil && sec.Channels.Telegram.Token != "" { cfg.Channels.Telegram.token = sec.Channels.Telegram.Token } // Handle Feishu credentials if sec.Channels.Feishu != nil { if sec.Channels.Feishu.AppSecret != "" { cfg.Channels.Feishu.appSecret = sec.Channels.Feishu.AppSecret } if sec.Channels.Feishu.EncryptKey != "" { cfg.Channels.Feishu.encryptKey = sec.Channels.Feishu.EncryptKey } if sec.Channels.Feishu.VerificationToken != "" { cfg.Channels.Feishu.verificationToken = sec.Channels.Feishu.VerificationToken } } // Handle Discord token if sec.Channels.Discord != nil && sec.Channels.Discord.Token != "" { cfg.Channels.Discord.token = sec.Channels.Discord.Token } // Handle Weixin token if sec.Channels.Weixin != nil && sec.Channels.Weixin.Token != "" { cfg.Channels.Discord.token = sec.Channels.Discord.Token } // Handle DingTalk client secret if sec.Channels.DingTalk != nil && sec.Channels.DingTalk.ClientSecret != "" { cfg.Channels.DingTalk.clientSecret = sec.Channels.DingTalk.ClientSecret } // Handle Slack tokens if sec.Channels.Slack != nil { if sec.Channels.Slack.BotToken != "" { cfg.Channels.Slack.botToken = sec.Channels.Slack.BotToken } if sec.Channels.Slack.AppToken != "" { cfg.Channels.Slack.appToken = sec.Channels.Slack.AppToken } } // Handle Matrix access token if sec.Channels.Matrix != nil && sec.Channels.Matrix.AccessToken != "" { cfg.Channels.Matrix.accessToken = sec.Channels.Matrix.AccessToken } // Handle LINE credentials if sec.Channels.LINE != nil { if sec.Channels.LINE.ChannelSecret != "" { cfg.Channels.LINE.channelSecret = sec.Channels.LINE.ChannelSecret } if sec.Channels.LINE.ChannelAccessToken != "" { cfg.Channels.LINE.channelAccessToken = sec.Channels.LINE.ChannelAccessToken } } // Handle OneBot access token if sec.Channels.OneBot != nil && sec.Channels.OneBot.AccessToken != "" { cfg.Channels.OneBot.accessToken = sec.Channels.OneBot.AccessToken } // Handle WeCom token and encoding key if sec.Channels.WeCom != nil { if sec.Channels.WeCom.Token != "" { cfg.Channels.WeCom.token = sec.Channels.WeCom.Token } if sec.Channels.WeCom.EncodingAESKey != "" { cfg.Channels.WeCom.encodingAESKey = sec.Channels.WeCom.EncodingAESKey } } // 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 } } // Handle Pico channel token if sec.Channels.Pico != nil && sec.Channels.Pico.Token != "" { cfg.Channels.Pico.token = sec.Channels.Pico.Token } // Handle IRC passwords if sec.Channels.IRC != nil { if sec.Channels.IRC.Password != "" { cfg.Channels.IRC.password = sec.Channels.IRC.Password } if sec.Channels.IRC.NickServPassword != "" { cfg.Channels.IRC.nickServPassword = sec.Channels.IRC.NickServPassword } if sec.Channels.IRC.SASLPassword != "" { cfg.Channels.IRC.saslPassword = sec.Channels.IRC.SASLPassword } } // Handle QQ app secret if sec.Channels.QQ != nil && sec.Channels.QQ.AppSecret != "" { cfg.Channels.QQ.appSecret = sec.Channels.QQ.AppSecret } cfg.security = sec return nil } func toNameIndex(list []*ModelConfig) []string { nameList := make([]string, 0, len(list)) countMap := make(map[string]int) for _, model := range list { name := model.ModelName index := countMap[name] nameList = append(nameList, fmt.Sprintf("%s:%d", name, index)) countMap[name]++ } return nameList } // encryptPlaintextAPIKeys returns a copy of models with plaintext api_key values // encrypted. Returns (nil, nil) when nothing changed (all keys already sealed or // empty). Returns (nil, error) if any key fails to encrypt — callers must treat // this as a hard failure to prevent a mixed plaintext/ciphertext state on disk. // Symmetric counterpart of resolveAPIKeys: both operate purely on []ModelConfig // and leave JSON marshaling to the caller. func encryptPlaintextAPIKeys( models map[string]ModelSecurityEntry, passphrase string, ) (map[string]ModelSecurityEntry, error) { sealed := make(map[string]ModelSecurityEntry, len(models)) changed := false for k, m := range models { sealedEntry := ModelSecurityEntry{APIKeys: make([]string, len(m.APIKeys))} // Encrypt each key in APIKeys for i, key := range m.APIKeys { if key == "" || strings.HasPrefix(key, "enc://") || strings.HasPrefix(key, "file://") { sealedEntry.APIKeys[i] = key continue } encrypted, err := credential.Encrypt(passphrase, "", key) if err != nil { return nil, fmt.Errorf("cannot seal api_key for model %q: %w", k, err) } sealedEntry.APIKeys[i] = encrypted changed = true } sealed[k] = sealedEntry } if !changed { return nil, nil } return sealed, nil } // resolveAPIKeys decrypts or dereferences each api_key in models in-place. // Supports plaintext (no-op), file:// (read from configDir), and enc:// (AES-GCM decrypt). func resolveAPIKeys(models []*ModelConfig, configDir string) error { cr := credential.NewResolver(configDir) for i := range models { // Resolve APIKeys array for j, key := range models[i].apiKeys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf( "model_list[%d] (%s): api_keys[%d]: %w", i, models[i].ModelName, j, err, ) } models[i].apiKeys[j] = resolved } } return nil } func (c *Config) migrateChannelConfigs() { // Discord: mention_only -> group_trigger.mention_only if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { c.Channels.Discord.GroupTrigger.MentionOnly = true } // OneBot: group_trigger_prefix -> group_trigger.prefixes if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix } } func SaveConfig(path string, cfg *Config) error { if cfg.security == nil { logger.Errorf("config %#v", *cfg) if len(cfg.ModelList) > 0 { logger.Errorf("model[0] %#v", cfg.ModelList[0]) } logger.ErrorC("config", "security is nil") return fmt.Errorf("security is nil") } // Ensure version is always set when saving if cfg.Version == 0 { cfg.Version = CurrentVersion } names := toNameIndex(cfg.ModelList) for i, m := range cfg.ModelList { if m.secDirty { if m.secModelName == "" { m.secModelName = names[i] } cfg.security.ModelList[m.secModelName] = ModelSecurityEntry{ APIKeys: m.apiKeys, } m.secDirty = false } } if cfg.Channels.Pico.secDirty { cfg.security.Channels.Pico = &PicoSecurity{ Token: cfg.Channels.Pico.Token(), } cfg.Channels.Pico.secDirty = false } if cfg.Channels.IRC.secDirty { cfg.security.Channels.IRC = &IRCSecurity{ Password: cfg.Channels.IRC.password, NickServPassword: cfg.Channels.IRC.nickServPassword, SASLPassword: cfg.Channels.IRC.saslPassword, } cfg.Channels.IRC.secDirty = false } if cfg.Channels.Telegram.secDirty { cfg.security.Channels.Telegram = &TelegramSecurity{ Token: cfg.Channels.Telegram.Token(), } cfg.Channels.Telegram.secDirty = false } if cfg.Channels.Feishu.secDirty { cfg.security.Channels.Feishu = &FeishuSecurity{ AppSecret: cfg.Channels.Feishu.AppSecret(), EncryptKey: cfg.Channels.Feishu.EncryptKey(), VerificationToken: cfg.Channels.Feishu.VerificationToken(), } cfg.Channels.Feishu.secDirty = false } if cfg.Channels.Discord.secDirty { cfg.security.Channels.Discord = &DiscordSecurity{ Token: cfg.Channels.Discord.Token(), } cfg.Channels.Discord.secDirty = false } if cfg.Channels.Weixin.secDirty { cfg.security.Channels.Weixin = &WeixinSecurity{ Token: cfg.Channels.Weixin.Token(), } cfg.Channels.Discord.secDirty = false } if cfg.Channels.QQ.secDirty { cfg.security.Channels.QQ = &QQSecurity{ AppSecret: cfg.Channels.QQ.AppSecret(), } cfg.Channels.QQ.secDirty = false } if cfg.Channels.DingTalk.secDirty { cfg.security.Channels.DingTalk = &DingTalkSecurity{ ClientSecret: cfg.Channels.DingTalk.ClientSecret(), } cfg.Channels.DingTalk.secDirty = false } if cfg.Channels.Slack.secDirty { cfg.security.Channels.Slack = &SlackSecurity{ BotToken: cfg.Channels.Slack.BotToken(), AppToken: cfg.Channels.Slack.AppToken(), } cfg.Channels.Slack.secDirty = false } if cfg.Channels.Matrix.secDirty { cfg.security.Channels.Matrix = &MatrixSecurity{ AccessToken: cfg.Channels.Matrix.AccessToken(), } cfg.Channels.Matrix.secDirty = false } if cfg.Channels.LINE.secDirty { cfg.security.Channels.LINE = &LINESecurity{ ChannelSecret: cfg.Channels.LINE.ChannelSecret(), ChannelAccessToken: cfg.Channels.LINE.ChannelAccessToken(), } cfg.Channels.LINE.secDirty = false } if cfg.Channels.OneBot.secDirty { cfg.security.Channels.OneBot = &OneBotSecurity{ AccessToken: cfg.Channels.OneBot.AccessToken(), } cfg.Channels.OneBot.secDirty = false } if cfg.Channels.WeCom.secDirty { cfg.security.Channels.WeCom = &WeComSecurity{ Token: cfg.Channels.WeCom.Token(), EncodingAESKey: cfg.Channels.WeCom.EncodingAESKey(), } 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(), } cfg.Tools.Web.Brave.secDirty = false } if cfg.Tools.Web.Tavily.secDirty { cfg.security.Web.Tavily = &TavilySecurity{ APIKeys: cfg.Tools.Web.Tavily.APIKeys(), } cfg.Tools.Web.Tavily.secDirty = false } if cfg.Tools.Web.Perplexity.secDirty { cfg.security.Web.Perplexity = &PerplexitySecurity{ APIKeys: cfg.Tools.Web.Perplexity.APIKeys(), } cfg.Tools.Web.Perplexity.secDirty = false } if cfg.Tools.Web.GLMSearch.secDirty { cfg.security.Web.GLMSearch = &GLMSearchSecurity{ APIKey: cfg.Tools.Web.GLMSearch.APIKey(), } cfg.Tools.Web.GLMSearch.secDirty = false } if cfg.Tools.Web.BaiduSearch.secDirty { cfg.security.Web.BaiduSearch = &BaiduSearchSecurity{ APIKey: cfg.Tools.Web.BaiduSearch.APIKey(), } cfg.Tools.Web.BaiduSearch.secDirty = false } if cfg.Tools.Skills.Github.secDirty { cfg.security.Skills.Github = &GithubSecurity{ Token: cfg.Tools.Skills.Github.Token(), } cfg.Tools.Skills.Github.secDirty = false } if cfg.Tools.Skills.Registries.ClawHub.secDirty { cfg.security.Skills.ClawHub = &ClawHubSecurity{ AuthToken: cfg.Tools.Skills.Registries.ClawHub.AuthToken(), } cfg.Tools.Skills.Registries.ClawHub.secDirty = false } if passphrase := credential.PassphraseProvider(); passphrase != "" { sealed, err := encryptPlaintextAPIKeys(cfg.security.ModelList, passphrase) if err != nil { return err } if sealed != nil { cfg.security.ModelList = sealed } } if err := saveSecurityConfig(securityPath(path), cfg.security); err != nil { logger.ErrorCF("config", "cannot save .security.yml", map[string]any{"error": err}) return err } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return fileutil.WriteFileAtomic(path, data, 0o600) } func (c *Config) WorkspacePath() string { return expandHome(c.Agents.Defaults.Workspace) } func expandHome(path string) string { if path == "" { return path } if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { return home + path[1:] } return home } return path } // GetModelConfig returns the ModelConfig for the given model name. // If multiple configs exist with the same model_name, it uses round-robin // selection for load balancing. Returns an error if the model is not found. func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) { matches := c.findMatches(modelName) if len(matches) == 0 { return nil, fmt.Errorf("model %q not found in model_list or providers", modelName) } if len(matches) == 1 { return matches[0], nil } // Multiple configs - use round-robin for load balancing idx := (rrCounter.Add(1) - 1) % uint64(len(matches)) return matches[idx], nil } // findMatches finds all ModelConfig entries with the given model_name. func (c *Config) findMatches(modelName string) []*ModelConfig { var matches []*ModelConfig for i := range c.ModelList { if c.ModelList[i].ModelName == modelName { matches = append(matches, c.ModelList[i]) } } return matches } // ValidateModelList validates all ModelConfig entries in the model_list. // It checks that each model config is valid. // Note: Multiple entries with the same model_name are allowed for load balancing. func (c *Config) ValidateModelList() error { for i := range c.ModelList { if err := c.ModelList[i].Validate(); err != nil { return fmt.Errorf("model_list[%d]: %w", i, err) } } return nil } func (c *Config) SecurityCopyFrom(cfg *Config) { c.security = cfg.security } func MergeAPIKeys(apiKey string, apiKeys []string) []string { seen := make(map[string]struct{}) var all []string if k := strings.TrimSpace(apiKey); k != "" { if _, exists := seen[k]; !exists { seen[k] = struct{}{} all = append(all, k) } } for _, k := range apiKeys { if trimmed := strings.TrimSpace(k); trimmed != "" { if _, exists := seen[trimmed]; !exists { seen[trimmed] = struct{}{} all = append(all, trimmed) } } } return all } // resolveSecurityFields resolves file:// and enc:// references in security-sensitive fields // like authToken and token that are not part of ModelConfig's apiKeys func resolveSecurityFields(cfg *Config, configDir string) error { cr := credential.NewResolver(configDir) // Resolve Web tool API keys - set apiKey field to first resolved apiKeys entry if len(cfg.Tools.Web.Brave.apiKeys) > 0 { keys := cfg.Tools.Web.Brave.apiKeys for i, key := range keys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf("brave api_keys[%d]: %w", i, err) } keys[i] = resolved } } if len(cfg.Tools.Web.Tavily.apiKeys) > 0 { keys := cfg.Tools.Web.Tavily.apiKeys for i, key := range keys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf("tavily api_keys[%d]: %w", i, err) } keys[i] = resolved } } if len(cfg.Tools.Web.Perplexity.apiKeys) > 0 { keys := cfg.Tools.Web.Perplexity.apiKeys for i, key := range keys { resolved, err := cr.Resolve(key) if err != nil { return fmt.Errorf("perplexity api_keys[%d]: %w", i, err) } keys[i] = resolved } } // GLMSearch has a private apiKey field if cfg.Tools.Web.GLMSearch.apiKey != "" { resolved, err := cr.Resolve(cfg.Tools.Web.GLMSearch.apiKey) if err != nil { return fmt.Errorf("glm api_key: %w", err) } cfg.Tools.Web.GLMSearch.apiKey = resolved } // Resolve Skills tokens if cfg.Tools.Skills.Github.token != "" { resolved, err := cr.Resolve(cfg.Tools.Skills.Github.token) if err != nil { return fmt.Errorf("github token: %w", err) } cfg.Tools.Skills.Github.token = resolved } if cfg.Tools.Skills.Registries.ClawHub.authToken != "" { resolved, err := cr.Resolve(cfg.Tools.Skills.Registries.ClawHub.authToken) if err != nil { return fmt.Errorf("clawhub auth_token: %w", err) } cfg.Tools.Skills.Registries.ClawHub.authToken = resolved } return nil } // expandMultiKeyModels expands ModelConfig entries with multiple API keys into // separate entries for key-level failover. Each key gets its own ModelConfig entry, // and the original entry's fallbacks are set up to chain through the expanded entries. // // Example: {"model_name": "gpt-4", "api_keys": ["k1", "k2", "k3"]} // Becomes: // - {"model_name": "gpt-4", "api_keys": ["k1"], "fallbacks": ["gpt-4__key_1", "gpt-4__key_2"]} // - {"model_name": "gpt-4__key_1", "api_keys": {"k2"}} // - {"model_name": "gpt-4__key_2", "api_keys": {"k3"}} func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig { var expanded []*ModelConfig for _, m := range models { keys := MergeAPIKeys("", m.apiKeys) // Single key or no keys: keep as-is if len(keys) <= 1 { m.apiKeys = keys expanded = append(expanded, m) continue } // Multiple keys: expand originalName := m.ModelName // Create entries for additional keys (key_1, key_2, ...) var fallbackNames []string for i := 1; i < len(keys); i++ { suffix := fmt.Sprintf("__key_%d", i) expandedName := originalName + suffix // Create a copy for the additional key additionalEntry := &ModelConfig{ ModelName: expandedName, Model: m.Model, APIBase: m.APIBase, apiKeys: []string{keys[i]}, Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, } expanded = append(expanded, additionalEntry) fallbackNames = append(fallbackNames, expandedName) } // Create the primary entry with first key and fallbacks primaryEntry := &ModelConfig{ ModelName: originalName, Model: m.Model, APIBase: m.APIBase, Proxy: m.Proxy, AuthMethod: m.AuthMethod, ConnectMode: m.ConnectMode, Workspace: m.Workspace, RPM: m.RPM, MaxTokensField: m.MaxTokensField, RequestTimeout: m.RequestTimeout, ThinkingLevel: m.ThinkingLevel, ExtraBody: m.ExtraBody, apiKeys: []string{keys[0]}, } // Prepend new fallbacks to existing ones if len(fallbackNames) > 0 { primaryEntry.Fallbacks = append(fallbackNames, m.Fallbacks...) } else if len(m.Fallbacks) > 0 { primaryEntry.Fallbacks = m.Fallbacks } expanded = append(expanded, primaryEntry) } return expanded } func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": return t.Web.Enabled case "cron": return t.Cron.Enabled case "exec": return t.Exec.Enabled case "skills": return t.Skills.Enabled case "media_cleanup": return t.MediaCleanup.Enabled case "append_file": return t.AppendFile.Enabled case "edit_file": return t.EditFile.Enabled case "find_skills": return t.FindSkills.Enabled case "i2c": return t.I2C.Enabled case "install_skill": return t.InstallSkill.Enabled case "list_dir": return t.ListDir.Enabled case "message": return t.Message.Enabled case "read_file": return t.ReadFile.Enabled case "spawn": return t.Spawn.Enabled case "spawn_status": return t.SpawnStatus.Enabled case "spi": return t.SPI.Enabled case "subagent": return t.Subagent.Enabled case "web_fetch": return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": return t.MCP.Enabled default: return true } }