Files
goclaw/cmd/onboard_managed.go
T
Viet TranandClaude Opus 4.6 f3f4c67b36 Initial commit: GoClaw AI agent gateway
Multi-agent AI gateway with WebSocket RPC, HTTP API, and messaging channel integrations.
Go port of OpenClaw with multi-tenant PostgreSQL, per-user isolation, security hardening,
and production observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:58:07 +07:00

339 lines
10 KiB
Go

package cmd
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
// testPostgresConnection verifies connectivity to Postgres with a 5s timeout.
func testPostgresConnection(dsn string) error {
db, err := pg.OpenDB(dsn)
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.PingContext(ctx)
}
// seedManagedData inserts providers, default model, and default agent into Postgres
// so the gateway has something to work with on first start.
// All providers with API keys are seeded (not just the default one).
// Idempotent: duplicate entries are skipped on re-run.
func seedManagedData(dsn string, cfg *config.Config) error {
storeCfg := store.StoreConfig{
PostgresDSN: dsn,
Mode: "managed",
EncryptionKey: os.Getenv("GOCLAW_ENCRYPTION_KEY"),
}
stores, err := pg.NewPGStores(storeCfg)
if err != nil {
return fmt.Errorf("open PG stores: %w", err)
}
ctx := context.Background()
defaultProvider := cfg.Agents.Defaults.Provider
if defaultProvider == "" {
defaultProvider = "openrouter"
}
// 1. Seed all providers that have API keys.
// Errors are non-fatal per provider (e.g. unique violation on re-run).
var seededCount int
for _, name := range providerPriority {
apiKey := resolveProviderAPIKey(cfg, name)
if apiKey == "" {
continue
}
providerType := "openai_compat"
if name == "anthropic" {
providerType = "anthropic_native"
}
p := &store.LLMProviderData{
Name: name,
DisplayName: name,
ProviderType: providerType,
APIBase: resolveProviderAPIBase(name),
APIKey: apiKey,
Enabled: true,
}
if err := stores.Providers.CreateProvider(ctx, p); err != nil {
slog.Debug("seed provider skipped (may already exist)", "name", name, "error", err)
continue
}
seededCount++
slog.Info("seeded provider", "name", name)
}
if seededCount > 0 {
fmt.Printf(" Seeded %d provider(s)\n", seededCount)
}
// 2. Find the default provider's ID from DB (handles both fresh seed and re-run).
allProviders, err := stores.Providers.ListProviders(ctx)
if err != nil {
return fmt.Errorf("list providers: %w", err)
}
var defaultProviderID uuid.UUID
for _, p := range allProviders {
if p.Name == defaultProvider {
defaultProviderID = p.ID
break
}
}
if defaultProviderID == uuid.Nil {
return fmt.Errorf("default provider %q not found in DB (no API key?)", defaultProvider)
}
// 3. Resolve default model string (used for agent seed below).
modelID := cfg.Agents.Defaults.Model
if modelID == "" {
modelID = "anthropic/claude-sonnet-4-5-20250929"
}
// 4. Seed default agent (skip if already exists)
workspace := config.ExpandHome(cfg.Agents.Defaults.Workspace)
agent := &store.AgentData{
AgentKey: "default",
DisplayName: "Default Agent",
OwnerID: "system",
AgentType: "open",
Provider: defaultProvider,
Model: modelID,
Workspace: workspace,
IsDefault: true,
Status: "active",
SubagentsConfig: json.RawMessage(`{"maxSpawnDepth":1,"maxConcurrent":20}`),
}
if err := stores.Agents.Create(ctx, agent); err != nil {
slog.Debug("seed agent skipped (may already exist)", "error", err)
return nil
}
// 5. Seed context files into agent_context_files (only for predefined agents;
// open agents get per-user files via SeedUserFiles on first chat)
if _, err := bootstrap.SeedToStore(ctx, stores.Agents, agent.ID, agent.AgentType); err != nil {
return fmt.Errorf("seed context files: %w", err)
}
// 6. Seed channel instances from env vars (if set)
seedChannelInstances(ctx, stores, cfg, agent.ID)
// 7. Seed config_secrets from env vars
seedConfigSecrets(ctx, stores, cfg)
return nil
}
// seedChannelInstances creates channel_instances rows from env-var-provided credentials.
// Idempotent: skips if an instance with the same name already exists.
func seedChannelInstances(ctx context.Context, stores *store.Stores, cfg *config.Config, defaultAgentID uuid.UUID) {
if stores.ChannelInstances == nil {
return
}
type seed struct {
name string // channel name in the system
channelType string
display string
creds map[string]string
config map[string]interface{}
}
var seeds []seed
// Telegram: use legacy name "telegram" for backward compat with existing session keys.
if cfg.Channels.Telegram.Token != "" {
tgConfig := map[string]interface{}{
"dm_policy": nonEmpty(cfg.Channels.Telegram.DMPolicy, "pairing"),
"group_policy": nonEmpty(cfg.Channels.Telegram.GroupPolicy, "pairing"),
"stream_mode": nonEmpty(cfg.Channels.Telegram.StreamMode, "none"),
"reaction_level": nonEmpty(cfg.Channels.Telegram.ReactionLevel, "full"),
"history_limit": nonZero(cfg.Channels.Telegram.HistoryLimit, 50),
}
if cfg.Channels.Telegram.RequireMention != nil {
tgConfig["require_mention"] = *cfg.Channels.Telegram.RequireMention
}
if cfg.Channels.Telegram.MediaMaxBytes > 0 {
tgConfig["media_max_bytes"] = cfg.Channels.Telegram.MediaMaxBytes
}
if cfg.Channels.Telegram.LinkPreview != nil {
tgConfig["link_preview"] = *cfg.Channels.Telegram.LinkPreview
}
if len(cfg.Channels.Telegram.AllowFrom) > 0 {
tgConfig["allow_from"] = cfg.Channels.Telegram.AllowFrom
}
seeds = append(seeds, seed{
name: "telegram", channelType: "telegram", display: "Telegram Bot",
creds: map[string]string{"token": cfg.Channels.Telegram.Token, "proxy": cfg.Channels.Telegram.Proxy},
config: tgConfig,
})
}
// Other channels: use {type}/default format (no legacy data to preserve).
if cfg.Channels.Discord.Token != "" {
seeds = append(seeds, seed{
name: "discord/default", channelType: "discord", display: "Discord Bot",
creds: map[string]string{"token": cfg.Channels.Discord.Token},
config: map[string]interface{}{"dm_policy": cfg.Channels.Discord.DMPolicy, "group_policy": cfg.Channels.Discord.GroupPolicy},
})
}
if cfg.Channels.Feishu.AppID != "" && cfg.Channels.Feishu.AppSecret != "" {
seeds = append(seeds, seed{
name: "feishu/default", channelType: "feishu", display: "Feishu/Lark Bot",
creds: map[string]string{
"app_id": cfg.Channels.Feishu.AppID, "app_secret": cfg.Channels.Feishu.AppSecret,
"encrypt_key": cfg.Channels.Feishu.EncryptKey, "verification_token": cfg.Channels.Feishu.VerificationToken,
},
config: map[string]interface{}{"dm_policy": cfg.Channels.Feishu.DMPolicy, "domain": cfg.Channels.Feishu.Domain},
})
}
if cfg.Channels.Zalo.Token != "" {
seeds = append(seeds, seed{
name: "zalo_oa/default", channelType: "zalo_oa", display: "Zalo OA",
creds: map[string]string{"token": cfg.Channels.Zalo.Token, "webhook_secret": cfg.Channels.Zalo.WebhookSecret},
config: map[string]interface{}{"dm_policy": cfg.Channels.Zalo.DMPolicy},
})
}
if cfg.Channels.WhatsApp.BridgeURL != "" {
seeds = append(seeds, seed{
name: "whatsapp/default", channelType: "whatsapp", display: "WhatsApp",
creds: map[string]string{"bridge_url": cfg.Channels.WhatsApp.BridgeURL},
config: map[string]interface{}{"dm_policy": cfg.Channels.WhatsApp.DMPolicy, "group_policy": cfg.Channels.WhatsApp.GroupPolicy},
})
}
seeded := 0
for _, s := range seeds {
credsJSON, _ := json.Marshal(s.creds)
cfgJSON, _ := json.Marshal(s.config)
inst := &store.ChannelInstanceData{
Name: s.name,
DisplayName: s.display,
ChannelType: s.channelType,
AgentID: defaultAgentID,
Credentials: credsJSON,
Config: cfgJSON,
Enabled: true,
CreatedBy: "system",
}
if err := stores.ChannelInstances.Create(ctx, inst); err != nil {
slog.Debug("seed channel instance skipped (may already exist)", "name", s.name, "error", err)
continue
}
seeded++
slog.Info("seeded channel instance", "name", s.name, "type", s.channelType)
}
if seeded > 0 {
fmt.Printf(" Seeded %d channel instance(s)\n", seeded)
}
}
// seedConfigSecrets saves non-LLM/non-channel secrets to the config_secrets table.
// These are secrets that don't belong in llm_providers or channel_instances tables.
func seedConfigSecrets(ctx context.Context, stores *store.Stores, cfg *config.Config) {
if stores.ConfigSecrets == nil {
return
}
secrets := cfg.ExtractDBSecrets()
seeded := 0
for key, value := range secrets {
if err := stores.ConfigSecrets.Set(ctx, key, value); err != nil {
slog.Debug("seed config secret failed", "key", key, "error", err)
continue
}
seeded++
}
if seeded > 0 {
slog.Info("seeded config secrets", "count", seeded)
}
}
// resolveProviderAPIKey extracts the API key for a provider from the config.
func resolveProviderAPIKey(cfg *config.Config, providerName string) string {
switch providerName {
case "openrouter":
return cfg.Providers.OpenRouter.APIKey
case "anthropic":
return cfg.Providers.Anthropic.APIKey
case "openai":
return cfg.Providers.OpenAI.APIKey
case "groq":
return cfg.Providers.Groq.APIKey
case "deepseek":
return cfg.Providers.DeepSeek.APIKey
case "gemini":
return cfg.Providers.Gemini.APIKey
case "mistral":
return cfg.Providers.Mistral.APIKey
case "xai":
return cfg.Providers.XAI.APIKey
case "minimax":
return cfg.Providers.MiniMax.APIKey
case "cohere":
return cfg.Providers.Cohere.APIKey
case "perplexity":
return cfg.Providers.Perplexity.APIKey
default:
return ""
}
}
// resolveProviderAPIBase returns the default API base URL for known providers.
func resolveProviderAPIBase(providerName string) string {
switch providerName {
case "openrouter":
return "https://openrouter.ai/api/v1"
case "anthropic":
return "https://api.anthropic.com"
case "openai":
return "https://api.openai.com/v1"
case "groq":
return "https://api.groq.com/openai/v1"
case "deepseek":
return "https://api.deepseek.com/v1"
case "gemini":
return "https://generativelanguage.googleapis.com/v1beta/openai"
case "mistral":
return "https://api.mistral.ai/v1"
case "xai":
return "https://api.x.ai/v1"
case "minimax":
return "https://api.minimax.io/v1"
case "cohere":
return "https://api.cohere.com/v2"
case "perplexity":
return "https://api.perplexity.ai"
default:
return ""
}
}