mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-19 18:15:41 +00:00
- Fix seedManagedData() hardcoding openai_compat for all non-Anthropic providers; now uses resolveProviderType() mapping to correct store constants (gemini_native, minimax_native, etc.) — fixes model listing in web UI for Gemini - Rename GOCLAW_FEISHU_* env vars to GOCLAW_LARK_* (Lark is the global brand) - Add WhatsApp env override (GOCLAW_WHATSAPP_BRIDGE_URL) and auto-enable - Add missing env vars to docker-compose.yml (Cohere, Perplexity, Lark, Zalo, WhatsApp) - Update .env.example with all providers/channels, remove unnecessary GOCLAW_PROVIDER - Add Cohere and Perplexity to prepare-env.sh provider detection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
368 lines
10 KiB
Go
368 lines
10 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/titanous/json5"
|
|
)
|
|
|
|
// Default returns a Config with sensible defaults.
|
|
func Default() *Config {
|
|
return &Config{
|
|
Agents: AgentsConfig{
|
|
Defaults: AgentDefaults{
|
|
Workspace: "~/.goclaw/workspace",
|
|
RestrictToWorkspace: true,
|
|
Provider: "anthropic",
|
|
Model: "claude-sonnet-4-5-20250929",
|
|
MaxTokens: 8192,
|
|
Temperature: 0.7,
|
|
MaxToolIterations: 20,
|
|
ContextWindow: 200000,
|
|
Subagents: &SubagentsConfig{
|
|
MaxConcurrent: 20,
|
|
MaxSpawnDepth: 1,
|
|
},
|
|
},
|
|
},
|
|
Channels: ChannelsConfig{
|
|
Telegram: TelegramConfig{
|
|
StreamMode: "none",
|
|
ReactionLevel: "full",
|
|
},
|
|
},
|
|
Gateway: GatewayConfig{
|
|
Host: "0.0.0.0",
|
|
Port: 18790,
|
|
MaxMessageChars: 32000,
|
|
RateLimitRPM: 20,
|
|
},
|
|
Tools: ToolsConfig{
|
|
Web: WebToolsConfig{
|
|
DuckDuckGo: DuckDuckGoConfig{Enabled: true, MaxResults: 5},
|
|
},
|
|
Browser: BrowserToolConfig{
|
|
Enabled: true,
|
|
Headless: true,
|
|
},
|
|
ExecApproval: ExecApprovalCfg{
|
|
Security: "full",
|
|
Ask: "off",
|
|
},
|
|
},
|
|
Sessions: SessionsConfig{
|
|
Storage: "~/.goclaw/sessions",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Load reads config from a JSON file, then overlays env vars.
|
|
func Load(path string) (*Config, error) {
|
|
cfg := Default()
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
cfg.applyEnvOverrides()
|
|
cfg.applyContextPruningDefaults()
|
|
return cfg, nil
|
|
}
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
|
|
if err := json5.Unmarshal(data, cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
cfg.applyEnvOverrides()
|
|
cfg.applyContextPruningDefaults()
|
|
return cfg, nil
|
|
}
|
|
|
|
// applyEnvOverrides overlays env vars onto the config.
|
|
// Env vars take precedence over file values.
|
|
func (c *Config) applyEnvOverrides() {
|
|
envStr := func(key string, dst *string) {
|
|
if v := os.Getenv(key); v != "" {
|
|
*dst = v
|
|
}
|
|
}
|
|
envStr("GOCLAW_ANTHROPIC_API_KEY", &c.Providers.Anthropic.APIKey)
|
|
envStr("GOCLAW_OPENAI_API_KEY", &c.Providers.OpenAI.APIKey)
|
|
envStr("GOCLAW_OPENROUTER_API_KEY", &c.Providers.OpenRouter.APIKey)
|
|
envStr("GOCLAW_GROQ_API_KEY", &c.Providers.Groq.APIKey)
|
|
envStr("GOCLAW_DEEPSEEK_API_KEY", &c.Providers.DeepSeek.APIKey)
|
|
envStr("GOCLAW_GEMINI_API_KEY", &c.Providers.Gemini.APIKey)
|
|
envStr("GOCLAW_MISTRAL_API_KEY", &c.Providers.Mistral.APIKey)
|
|
envStr("GOCLAW_XAI_API_KEY", &c.Providers.XAI.APIKey)
|
|
envStr("GOCLAW_MINIMAX_API_KEY", &c.Providers.MiniMax.APIKey)
|
|
envStr("GOCLAW_COHERE_API_KEY", &c.Providers.Cohere.APIKey)
|
|
envStr("GOCLAW_PERPLEXITY_API_KEY", &c.Providers.Perplexity.APIKey)
|
|
envStr("GOCLAW_GATEWAY_TOKEN", &c.Gateway.Token)
|
|
envStr("GOCLAW_TELEGRAM_TOKEN", &c.Channels.Telegram.Token)
|
|
envStr("GOCLAW_DISCORD_TOKEN", &c.Channels.Discord.Token)
|
|
envStr("GOCLAW_ZALO_TOKEN", &c.Channels.Zalo.Token)
|
|
envStr("GOCLAW_LARK_APP_ID", &c.Channels.Feishu.AppID)
|
|
envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret)
|
|
envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey)
|
|
envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken)
|
|
envStr("GOCLAW_WHATSAPP_BRIDGE_URL", &c.Channels.WhatsApp.BridgeURL)
|
|
|
|
// TTS secrets
|
|
envStr("GOCLAW_TTS_OPENAI_API_KEY", &c.Tts.OpenAI.APIKey)
|
|
envStr("GOCLAW_TTS_ELEVENLABS_API_KEY", &c.Tts.ElevenLabs.APIKey)
|
|
envStr("GOCLAW_TTS_MINIMAX_API_KEY", &c.Tts.MiniMax.APIKey)
|
|
envStr("GOCLAW_TTS_MINIMAX_GROUP_ID", &c.Tts.MiniMax.GroupID)
|
|
|
|
// Auto-enable channels if credentials are provided via env
|
|
if c.Channels.Telegram.Token != "" {
|
|
c.Channels.Telegram.Enabled = true
|
|
}
|
|
if c.Channels.Discord.Token != "" {
|
|
c.Channels.Discord.Enabled = true
|
|
}
|
|
if c.Channels.Zalo.Token != "" {
|
|
c.Channels.Zalo.Enabled = true
|
|
}
|
|
if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" {
|
|
c.Channels.Feishu.Enabled = true
|
|
}
|
|
if c.Channels.WhatsApp.BridgeURL != "" {
|
|
c.Channels.WhatsApp.Enabled = true
|
|
}
|
|
|
|
// Allow overriding default provider/model
|
|
envStr("GOCLAW_PROVIDER", &c.Agents.Defaults.Provider)
|
|
envStr("GOCLAW_MODEL", &c.Agents.Defaults.Model)
|
|
|
|
// Workspace & sessions
|
|
envStr("GOCLAW_WORKSPACE", &c.Agents.Defaults.Workspace)
|
|
envStr("GOCLAW_SESSIONS_STORAGE", &c.Sessions.Storage)
|
|
|
|
// Gateway host/port
|
|
envStr("GOCLAW_HOST", &c.Gateway.Host)
|
|
if v := os.Getenv("GOCLAW_PORT"); v != "" {
|
|
if port, err := strconv.Atoi(v); err == nil && port > 0 {
|
|
c.Gateway.Port = port
|
|
}
|
|
}
|
|
|
|
// Database
|
|
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
|
|
envStr("GOCLAW_MODE", &c.Database.Mode)
|
|
|
|
// Telemetry
|
|
envStr("GOCLAW_TELEMETRY_ENDPOINT", &c.Telemetry.Endpoint)
|
|
envStr("GOCLAW_TELEMETRY_PROTOCOL", &c.Telemetry.Protocol)
|
|
envStr("GOCLAW_TELEMETRY_SERVICE_NAME", &c.Telemetry.ServiceName)
|
|
if v := os.Getenv("GOCLAW_TELEMETRY_ENABLED"); v != "" {
|
|
c.Telemetry.Enabled = v == "true" || v == "1"
|
|
}
|
|
if v := os.Getenv("GOCLAW_TELEMETRY_INSECURE"); v != "" {
|
|
c.Telemetry.Insecure = v == "true" || v == "1"
|
|
}
|
|
|
|
// Owner IDs from env (comma-separated)
|
|
if v := os.Getenv("GOCLAW_OWNER_IDS"); v != "" {
|
|
c.Gateway.OwnerIDs = strings.Split(v, ",")
|
|
}
|
|
|
|
// Tailscale (tsnet)
|
|
envStr("GOCLAW_TSNET_HOSTNAME", &c.Tailscale.Hostname)
|
|
envStr("GOCLAW_TSNET_AUTH_KEY", &c.Tailscale.AuthKey)
|
|
envStr("GOCLAW_TSNET_DIR", &c.Tailscale.StateDir)
|
|
|
|
// Sandbox (for Docker-compose sandbox overlay)
|
|
ensureSandbox := func() {
|
|
if c.Agents.Defaults.Sandbox == nil {
|
|
c.Agents.Defaults.Sandbox = &SandboxConfig{}
|
|
}
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_MODE"); v != "" {
|
|
ensureSandbox()
|
|
c.Agents.Defaults.Sandbox.Mode = v
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_IMAGE"); v != "" {
|
|
ensureSandbox()
|
|
c.Agents.Defaults.Sandbox.Image = v
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_WORKSPACE_ACCESS"); v != "" {
|
|
ensureSandbox()
|
|
c.Agents.Defaults.Sandbox.WorkspaceAccess = v
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_SCOPE"); v != "" {
|
|
ensureSandbox()
|
|
c.Agents.Defaults.Sandbox.Scope = v
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_MEMORY_MB"); v != "" {
|
|
ensureSandbox()
|
|
if mb, err := strconv.Atoi(v); err == nil && mb > 0 {
|
|
c.Agents.Defaults.Sandbox.MemoryMB = mb
|
|
}
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_CPUS"); v != "" {
|
|
ensureSandbox()
|
|
if cpus, err := strconv.ParseFloat(v, 64); err == nil && cpus > 0 {
|
|
c.Agents.Defaults.Sandbox.CPUs = cpus
|
|
}
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_TIMEOUT_SEC"); v != "" {
|
|
ensureSandbox()
|
|
if sec, err := strconv.Atoi(v); err == nil && sec > 0 {
|
|
c.Agents.Defaults.Sandbox.TimeoutSec = sec
|
|
}
|
|
}
|
|
if v := os.Getenv("GOCLAW_SANDBOX_NETWORK"); v != "" {
|
|
ensureSandbox()
|
|
c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1"
|
|
}
|
|
}
|
|
|
|
// applyContextPruningDefaults auto-enables context pruning when the Anthropic
|
|
// provider is configured, matching TS applyContextPruningDefaults() in
|
|
// src/config/defaults.ts.
|
|
//
|
|
// Go port does not have OAuth vs API-key distinction — we always treat it as
|
|
// API-key mode (heartbeat 30m).
|
|
func (c *Config) applyContextPruningDefaults() {
|
|
// Only apply when Anthropic is configured.
|
|
if c.Providers.Anthropic.APIKey == "" {
|
|
return
|
|
}
|
|
|
|
defaults := &c.Agents.Defaults
|
|
|
|
// Auto-enable context pruning if mode not explicitly set.
|
|
if defaults.ContextPruning == nil {
|
|
defaults.ContextPruning = &ContextPruningConfig{
|
|
Mode: "cache-ttl",
|
|
}
|
|
} else if defaults.ContextPruning.Mode == "" {
|
|
defaults.ContextPruning.Mode = "cache-ttl"
|
|
}
|
|
}
|
|
|
|
// Save writes the config to a JSON file.
|
|
func Save(path string, cfg *Config) error {
|
|
cfg.mu.RLock()
|
|
defer cfg.mu.RUnlock()
|
|
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(path, data, 0600)
|
|
}
|
|
|
|
// Hash returns a SHA-256 hash of the config for optimistic concurrency.
|
|
func (c *Config) Hash() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
data, _ := json.Marshal(c)
|
|
h := sha256.Sum256(data)
|
|
return fmt.Sprintf("%x", h[:8])
|
|
}
|
|
|
|
// WorkspacePath returns the expanded workspace path.
|
|
func (c *Config) WorkspacePath() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return ExpandHome(c.Agents.Defaults.Workspace)
|
|
}
|
|
|
|
// ResolveAgent returns the effective config for a given agent ID,
|
|
// merging defaults with per-agent overrides.
|
|
func (c *Config) ResolveAgent(agentID string) AgentDefaults {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
d := c.Agents.Defaults
|
|
if spec, ok := c.Agents.List[agentID]; ok {
|
|
if spec.Provider != "" {
|
|
d.Provider = spec.Provider
|
|
}
|
|
if spec.Model != "" {
|
|
d.Model = spec.Model
|
|
}
|
|
if spec.MaxTokens > 0 {
|
|
d.MaxTokens = spec.MaxTokens
|
|
}
|
|
if spec.Temperature > 0 {
|
|
d.Temperature = spec.Temperature
|
|
}
|
|
if spec.MaxToolIterations > 0 {
|
|
d.MaxToolIterations = spec.MaxToolIterations
|
|
}
|
|
if spec.ContextWindow > 0 {
|
|
d.ContextWindow = spec.ContextWindow
|
|
}
|
|
if spec.Workspace != "" {
|
|
d.Workspace = spec.Workspace
|
|
}
|
|
if spec.Sandbox != nil {
|
|
d.Sandbox = spec.Sandbox
|
|
}
|
|
if spec.AgentType != "" {
|
|
d.AgentType = spec.AgentType
|
|
}
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
// ResolveDefaultAgentID returns the ID of the agent marked as default,
|
|
// or "default" if none is explicitly marked.
|
|
func (c *Config) ResolveDefaultAgentID() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
for id, spec := range c.Agents.List {
|
|
if spec.Default {
|
|
return id
|
|
}
|
|
}
|
|
return DefaultAgentID
|
|
}
|
|
|
|
// ResolveDisplayName returns the display name for an agent.
|
|
// Falls back to "GoClaw" if not configured.
|
|
func (c *Config) ResolveDisplayName(agentID string) string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if spec, ok := c.Agents.List[agentID]; ok && spec.DisplayName != "" {
|
|
return spec.DisplayName
|
|
}
|
|
return "GoClaw"
|
|
}
|
|
|
|
// ApplyEnvOverrides re-applies environment variable overrides onto the config.
|
|
// Call this after modifying config to restore runtime secrets from env vars.
|
|
func (c *Config) ApplyEnvOverrides() {
|
|
c.applyEnvOverrides()
|
|
c.applyContextPruningDefaults()
|
|
}
|
|
|
|
// ExpandHome replaces leading ~ with the user home directory.
|
|
func ExpandHome(path string) string {
|
|
if path == "" || path[0] != '~' {
|
|
return path
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
if len(path) > 1 && path[1] == '/' {
|
|
return home + path[1:]
|
|
}
|
|
return home
|
|
}
|