package cmd import ( "context" "encoding/json" "log/slog" "net" "os/exec" "path/filepath" "regexp" "strconv" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) // loopbackAddr normalizes a gateway address for local connections. // CLI processes on the same machine can't connect to 0.0.0.0 on some OSes. func loopbackAddr(host string, port int) string { if host == "" || host == "0.0.0.0" || host == "::" { host = "127.0.0.1" } return net.JoinHostPort(host, strconv.Itoa(port)) } func registerProviders(registry *providers.Registry, cfg *config.Config, modelReg providers.ModelRegistry) { if cfg.Providers.Anthropic.APIKey != "" { registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey, providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase), providers.WithAnthropicRegistry(modelReg))) slog.Info("registered provider", "name", "anthropic") } if cfg.Providers.OpenAI.APIKey != "" { registry.Register(providers.NewOpenAIProvider("openai", cfg.Providers.OpenAI.APIKey, cfg.Providers.OpenAI.APIBase, "gpt-4o"). WithRegistry(modelReg)) slog.Info("registered provider", "name", "openai") } if cfg.Providers.AtlasCloud.APIKey != "" { base := cfg.Providers.AtlasCloud.APIBase if base == "" { base = store.AtlasCloudDefaultAPIBase } prov := providers.NewOpenAIProvider("atlascloud", cfg.Providers.AtlasCloud.APIKey, base, store.AtlasCloudDefaultModel) prov.WithProviderType(store.ProviderAtlasCloud) registry.Register(prov) slog.Info("registered provider", "name", "atlascloud") } if cfg.Providers.APIRoute.APIKey != "" { base := cfg.Providers.APIRoute.APIBase if base == "" { base = store.APIRouteDefaultAPIBase } prov := providers.NewOpenAIProvider("api_route", cfg.Providers.APIRoute.APIKey, base, store.APIRouteDefaultModel) prov.WithProviderType(store.ProviderAPIRoute) registry.Register(prov) slog.Info("registered provider", "name", "api_route") } if cfg.Providers.Requesty.APIKey != "" { base := cfg.Providers.Requesty.APIBase if base == "" { base = store.RequestyDefaultAPIBase } prov := providers.NewOpenAIProvider("requesty", cfg.Providers.Requesty.APIKey, base, store.RequestyDefaultModel) prov.WithProviderType(store.ProviderRequesty) prov.WithSiteInfo("https://goclaw.sh", "GoClaw") registry.Register(prov) slog.Info("registered provider", "name", "requesty") } if cfg.Providers.OpenRouter.APIKey != "" { orProv := providers.NewOpenAIProvider("openrouter", cfg.Providers.OpenRouter.APIKey, "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4-5-20250929") orProv.WithSiteInfo("https://goclaw.sh", "GoClaw") registry.Register(orProv) slog.Info("registered provider", "name", "openrouter") } if cfg.Providers.Groq.APIKey != "" { registry.Register(providers.NewOpenAIProvider("groq", cfg.Providers.Groq.APIKey, "https://api.groq.com/openai/v1", "llama-3.3-70b-versatile")) slog.Info("registered provider", "name", "groq") } if cfg.Providers.DeepSeek.APIKey != "" { registry.Register(providers.NewOpenAIProvider("deepseek", cfg.Providers.DeepSeek.APIKey, "https://api.deepseek.com/v1", "deepseek-chat")) slog.Info("registered provider", "name", "deepseek") } if cfg.Providers.Gemini.APIKey != "" { registry.Register(providers.NewOpenAIProvider("gemini", cfg.Providers.Gemini.APIKey, "https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash")) slog.Info("registered provider", "name", "gemini") } if cfg.Providers.Mistral.APIKey != "" { registry.Register(providers.NewOpenAIProvider("mistral", cfg.Providers.Mistral.APIKey, "https://api.mistral.ai/v1", "mistral-large-latest")) slog.Info("registered provider", "name", "mistral") } if cfg.Providers.XAI.APIKey != "" { registry.Register(providers.NewOpenAIProvider("xai", cfg.Providers.XAI.APIKey, "https://api.x.ai/v1", "grok-3-mini")) slog.Info("registered provider", "name", "xai") } if cfg.Providers.MiniMax.APIKey != "" { base := cfg.Providers.MiniMax.APIBase if base == "" { base = store.MiniMaxDefaultAPIBase } registry.Register(providers.NewOpenAIProvider("minimax", cfg.Providers.MiniMax.APIKey, base, store.MiniMaxDefaultModel)) slog.Info("registered provider", "name", "minimax") } if cfg.Providers.Cohere.APIKey != "" { registry.Register(providers.NewOpenAIProvider("cohere", cfg.Providers.Cohere.APIKey, "https://api.cohere.ai/compatibility/v1", "command-a")) slog.Info("registered provider", "name", "cohere") } if cfg.Providers.Perplexity.APIKey != "" { registry.Register(providers.NewOpenAIProvider("perplexity", cfg.Providers.Perplexity.APIKey, "https://api.perplexity.ai", "sonar-pro")) slog.Info("registered provider", "name", "perplexity") } if cfg.Providers.DashScope.APIKey != "" { registry.Register(providers.NewDashScopeProvider("dashscope", cfg.Providers.DashScope.APIKey, cfg.Providers.DashScope.APIBase, "qwen3-max")) slog.Info("registered provider", "name", "dashscope") } if cfg.Providers.Bailian.APIKey != "" { base := cfg.Providers.Bailian.APIBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } registry.Register(providers.NewOpenAIProvider("bailian", cfg.Providers.Bailian.APIKey, base, "qwen3.5-plus"). WithProviderType(store.ProviderBailian)) slog.Info("registered provider", "name", "bailian") } if cfg.Providers.Zai.APIKey != "" { base := cfg.Providers.Zai.APIBase if base == "" { base = store.ZaiDefaultAPIBase } registry.Register(providers.NewOpenAIProvider("zai", cfg.Providers.Zai.APIKey, base, store.ZaiDefaultModel)) slog.Info("registered provider", "name", "zai") } if cfg.Providers.ZaiCoding.APIKey != "" { base := cfg.Providers.ZaiCoding.APIBase if base == "" { base = store.ZaiCodingDefaultAPIBase } registry.Register(providers.NewOpenAIProvider("zai-coding", cfg.Providers.ZaiCoding.APIKey, base, store.ZaiDefaultModel)) slog.Info("registered provider", "name", "zai-coding") } // Local / self-hosted Ollama — gated on Host, no API key required. // Uses the native Ollama Go client for proper options.num_ctx support. if cfg.Providers.Ollama.Host != "" { host := cfg.Providers.Ollama.Host ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second) numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(host), "llama3.3", "") cancel() var numCtxPtr *int if numCtx != providers.OllamaDefaultNumCtx { numCtxPtr = &numCtx } registry.Register(providers.NewOllamaProvider("ollama", host, "llama3.3", numCtxPtr, nil)) slog.Info("registered provider", "name", "ollama") } // Ollama Cloud — API key required (generate at ollama.com/settings/keys). // Uses the native Ollama Go client; the cloud endpoint is Ollama-native, not OpenAI-compat. if cfg.Providers.OllamaCloud.APIKey != "" { base := cfg.Providers.OllamaCloud.APIBase if base == "" { base = "https://ollama.com" } ctx5s, cancel := context.WithTimeout(context.Background(), 5*time.Second) numCtx := providers.FetchOllamaModelContext(ctx5s, config.DockerLocalhost(base), "llama3.3", "") cancel() var numCtxPtr *int if numCtx != providers.OllamaDefaultNumCtx { numCtxPtr = &numCtx } registry.Register(providers.NewOllamaProvider("ollama-cloud", base, "llama3.3", numCtxPtr, nil)) slog.Info("registered provider", "name", "ollama-cloud") } // Novita AI — OpenAI-compatible endpoint. if cfg.Providers.Novita.APIKey != "" { base := cfg.Providers.Novita.APIBase if base == "" { base = store.NovitaDefaultAPIBase } registry.Register(providers.NewOpenAIProvider("novita", cfg.Providers.Novita.APIKey, base, store.NovitaDefaultModel)) slog.Info("registered provider", "name", "novita") } // BytePlus ModelArk — OpenAI-compatible (standard Bearer auth). if cfg.Providers.BytePlus.APIKey != "" { base := cfg.Providers.BytePlus.APIBase if base == "" { base = store.BytePlusDefaultAPIBase } prov := providers.NewOpenAIProvider("byteplus", cfg.Providers.BytePlus.APIKey, base, store.BytePlusDefaultModel) prov.WithProviderType(store.ProviderBytePlus) registry.Register(prov) slog.Info("registered provider", "name", "byteplus") } // BytePlus ModelArk Coding Plan — separate endpoint for developer tools quota. if cfg.Providers.BytePlusCoding.APIKey != "" { base := cfg.Providers.BytePlusCoding.APIBase if base == "" { base = store.BytePlusCodingDefaultAPIBase } prov := providers.NewOpenAIProvider("byteplus-coding", cfg.Providers.BytePlusCoding.APIKey, base, store.BytePlusDefaultModel) prov.WithProviderType(store.ProviderBytePlusCoding) registry.Register(prov) slog.Info("registered provider", "name", "byteplus-coding") } // Google Cloud Vertex AI — OAuth2 service account or Application Default Credentials. // Registers when project_id + region are set. Credential sources (priority order): // inline JSON (APIKey) → file path (CredentialsFile) → ADC. if cfg.Providers.Vertex.ProjectID != "" && cfg.Providers.Vertex.Region != "" { vcfg := providers.VertexConfig{ Name: "vertex", CredentialsJSON: cfg.Providers.Vertex.APIKey, CredentialsFile: cfg.Providers.Vertex.CredentialsFile, ProjectID: cfg.Providers.Vertex.ProjectID, Region: cfg.Providers.Vertex.Region, DefaultModel: cfg.Providers.Vertex.Model, } prov, err := providers.NewVertexProviderWithTimeout(vcfg) if err != nil { slog.Warn("vertex: initialization failed", "error", err) } else { registry.Register(prov) slog.Info("registered provider", "name", "vertex", "region", cfg.Providers.Vertex.Region, "project", cfg.Providers.Vertex.ProjectID) } } registerClaudeCLIFromConfig(registry, cfg) // ACP provider (config-based) — orchestrates any ACP-compatible agent binary if cfg.Providers.ACP.Binary != "" { registerACPFromConfig(registry, cfg.Providers.ACP, configuredShellDenyGroups(cfg)) } } // buildMCPServerLookup creates an MCPServerLookup from an MCPServerStore. // Returns nil if mcpStore is nil. func buildMCPServerLookup(mcpStore store.MCPServerStore) providers.MCPServerLookup { if mcpStore == nil { return nil } return func(ctx context.Context, agentID string) []providers.MCPServerEntry { aid, err := uuid.Parse(agentID) if err != nil { return nil } accessible, err := mcpStore.ListAccessible(ctx, aid, "") if err != nil { slog.Warn("claude-cli: failed to list agent MCP servers", "agent_id", agentID, "error", err) return nil } var entries []providers.MCPServerEntry for _, info := range accessible { srv := info.Server if !srv.Enabled { continue } entry := providers.MCPServerEntry{ Name: srv.Name, Transport: srv.Transport, Command: srv.Command, URL: srv.URL, Args: jsonToStringSlice(srv.Args), Headers: jsonToStringMap(srv.Headers), Env: jsonToStringMap(srv.Env), } entries = append(entries, entry) } return entries } } // jsonToStringSlice converts a json.RawMessage to []string. func jsonToStringSlice(data json.RawMessage) []string { if len(data) == 0 { return nil } var result []string if err := json.Unmarshal(data, &result); err != nil { return nil } return result } // jsonToStringMap converts a json.RawMessage to map[string]string. func jsonToStringMap(data json.RawMessage) map[string]string { if len(data) == 0 { return nil } var result map[string]string if err := json.Unmarshal(data, &result); err != nil { return nil } return result } // registerProvidersFromDB loads providers from Postgres and registers them. // DB providers are registered after config providers, so they take precedence (overwrite). // gatewayAddr is used to inject GoClaw MCP bridge for Claude CLI providers. // mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config. // cfg provides fallback api_base values from config/env when DB providers have none set. func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config, modelReg providers.ModelRegistry) { dbProviders, err := provStore.ListAllProviders(context.Background()) if err != nil { slog.Warn("failed to load providers from DB", "error", err) return } for _, p := range dbProviders { // Claude CLI doesn't need API key if !p.Enabled { continue } if p.ProviderType == store.ProviderClaudeCLI { registerClaudeCLIFromDB(registry, p, gatewayAddr, gatewayToken, mcpStore, cfg) continue } // ACP provider — no API key needed (agents manage their own auth). if p.ProviderType == store.ProviderACP { registerACPFromDB(registry, p, configuredShellDenyGroups(cfg)) continue } // Local Ollama requires no API key — handle before the key guard (same pattern as ClaudeCLI). // api_base is stored with /v1 (normalized at write time), so no suffix appending needed. if p.ProviderType == store.ProviderOllama { host := p.APIBase if host == "" { host = "http://localhost:11434" } numCtx := resolveOllamaNumCtx(&p) prov := providers.NewOllamaProvider(p.Name, config.DockerLocalhost(host), "llama3.3", numCtx, nil). WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings)) registry.RegisterForTenant(p.TenantID, prov) slog.Info("registered provider from DB", "name", p.Name) continue } // Vertex supports ADC (empty api_key) — handle before the generic key guard. if p.ProviderType == store.ProviderVertex { vsettings := store.ParseVertexProviderSettings(p.Settings) if vsettings == nil { slog.Warn("vertex: missing project_id/region in settings, skipping", "name", p.Name) continue } vcfg := providers.VertexConfig{ Name: p.Name, CredentialsJSON: p.APIKey, ProjectID: vsettings.ProjectID, Region: vsettings.Region, DefaultModel: vsettings.Model, APIBaseOverride: p.APIBase, } prov, err := providers.NewVertexProviderWithTimeout(vcfg) if err != nil { slog.Warn("vertex: init from DB failed", "name", p.Name, "error", err) continue } registry.RegisterForTenant(p.TenantID, prov) slog.Info("registered provider from DB", "name", p.Name, "type", "vertex", "region", vsettings.Region) continue } if p.APIKey == "" { continue } // Fall back to config/env api_base when DB provider has none set. if p.APIBase == "" && cfg != nil { if base := cfg.Providers.APIBaseForType(p.ProviderType); base != "" { p.APIBase = base slog.Info("provider api_base inherited from config", "name", p.Name, "api_base", base) } } switch p.ProviderType { case store.ProviderChatGPTOAuth: ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID) codex := providers.NewCodexProvider(p.Name, ts, p.APIBase, "") if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil { codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames) } registry.RegisterForTenant(p.TenantID, codex) case store.ProviderAnthropicNative: registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicName(p.Name), providers.WithAnthropicBaseURL(p.APIBase), providers.WithAnthropicRegistry(modelReg))) case store.ProviderDashScope: registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) case store.ProviderBailian: base := p.APIBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"). WithProviderType(p.ProviderType)) case store.ProviderZai: base := p.APIBase if base == "" { base = store.ZaiDefaultAPIBase } registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) case store.ProviderZaiCoding: base := p.APIBase if base == "" { base = store.ZaiCodingDefaultAPIBase } registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.ZaiDefaultModel)) case store.ProviderOllamaCloud: base := p.APIBase if base == "" { base = "https://ollama.com" } numCtx := resolveOllamaNumCtx(&p) prov := providers.NewOllamaProvider(p.Name, base, "llama3.3", numCtx, nil). WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings)) registry.RegisterForTenant(p.TenantID, prov) case store.ProviderNovita: base := p.APIBase if base == "" { base = store.NovitaDefaultAPIBase } registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel)) case store.ProviderBytePlus: base := p.APIBase if base == "" { base = store.BytePlusDefaultAPIBase } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel) prov.WithProviderType(p.ProviderType) registry.RegisterForTenant(p.TenantID, prov) case store.ProviderBytePlusCoding: base := p.APIBase if base == "" { base = store.BytePlusCodingDefaultAPIBase } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.BytePlusDefaultModel) prov.WithProviderType(p.ProviderType) registry.RegisterForTenant(p.TenantID, prov) case store.ProviderKimiCoding: // Moonshot Kimi Coding requires a fixed User-Agent on every request. // OpenAI-compatible wire shape otherwise. base := p.APIBase if base == "" { base = store.KimiCodingDefaultAPIBase } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.KimiCodingDefaultModel) prov.WithProviderType(p.ProviderType) prov.WithExtraHeaders(map[string]string{ "User-Agent": store.KimiCodingRequiredUserAgent, }) registry.RegisterForTenant(p.TenantID, prov) case store.ProviderAIMLAPI: prov := providers.NewAIMLAPIProvider(p.Name, p.APIKey, p.APIBase) prov.WithProviderType(p.ProviderType) registry.RegisterForTenant(p.TenantID, prov) case store.ProviderAPIRoute: base := p.APIBase if base == "" { base = store.APIRouteDefaultAPIBase } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.APIRouteDefaultModel) prov.WithProviderType(p.ProviderType) registry.RegisterForTenant(p.TenantID, prov) default: base, model := openAIProviderDefaults(p.ProviderType, p.APIBase) prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, model) prov.WithProviderType(p.ProviderType) prov.WithThinkingEnabled(store.ParseThinkingEnabled(p.Settings)) if p.ProviderType == store.ProviderOpenRouter || p.ProviderType == store.ProviderRequesty { prov.WithSiteInfo("https://goclaw.sh", "GoClaw") } registry.RegisterForTenant(p.TenantID, prov) } slog.Info("registered provider from DB", "name", p.Name) } } func openAIProviderDefaults(providerType, apiBase string) (string, string) { switch providerType { case store.ProviderMiniMax: if apiBase == "" { apiBase = store.MiniMaxDefaultAPIBase } return apiBase, store.MiniMaxDefaultModel case store.ProviderAtlasCloud: if apiBase == "" { apiBase = store.AtlasCloudDefaultAPIBase } return apiBase, store.AtlasCloudDefaultModel case store.ProviderRequesty: if apiBase == "" { apiBase = store.RequestyDefaultAPIBase } return apiBase, store.RequestyDefaultModel default: return apiBase, "" } } // resolveOllamaNumCtx returns the operator-configured num_ctx for an Ollama // provider, or nil to let the provider resolve it per model at request time. // // Only the explicit settings JSONB override is honoured here. Probing /api/show // at startup cannot work: the model an agent will use is not known until it // sends a request, so the probe had to guess a model name, and a wrong guess // resolved to nothing. OllamaProvider.resolveNumCtx does the lookup against the // real model instead, and caches it. func resolveOllamaNumCtx(p *store.LLMProviderData) *int { if s := store.ParseOllamaSettings(p.Settings); s != nil { return s.NumCtx } return nil } func registerClaudeCLIFromConfig(registry *providers.Registry, cfg *config.Config) { if cfg == nil || cfg.Providers.ClaudeCLI.CLIPath == "" { return } cliPath := cfg.Providers.ClaudeCLI.CLIPath var opts []providers.ClaudeCLIOption if cfg.Providers.ClaudeCLI.Model != "" { opts = append(opts, providers.WithClaudeCLIModel(cfg.Providers.ClaudeCLI.Model)) } if cfg.Providers.ClaudeCLI.BaseWorkDir != "" { opts = append(opts, providers.WithClaudeCLIWorkDir(cfg.Providers.ClaudeCLI.BaseWorkDir)) } if cfg.Providers.ClaudeCLI.PermMode != "" { opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode)) } gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port) mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token) opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData)) opts = append(opts, providers.WithClaudeCLISecurityHooks( cfg.Providers.ClaudeCLI.BaseWorkDir, true, configuredShellDenyPatterns(cfg))) registry.Register(providers.NewClaudeCLIProvider(cliPath, opts...)) slog.Info("registered provider", "name", "claude-cli") } func registerClaudeCLIFromDB(registry *providers.Registry, p store.LLMProviderData, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) bool { cliPath := p.APIBase // reuse APIBase field for CLI path if cliPath == "" { cliPath = "claude" } // Validate: only accept "claude" or absolute path if cliPath != "claude" && !filepath.IsAbs(cliPath) { slog.Warn("security.claude_cli: invalid path from DB, using default", "path", cliPath) cliPath = "claude" } if _, err := exec.LookPath(cliPath); err != nil { slog.Warn("claude-cli: binary not found, skipping", "path", cliPath, "error", err) return false } var cliOpts []providers.ClaudeCLIOption cliOpts = append(cliOpts, providers.WithClaudeCLIName(p.Name)) cliOpts = append(cliOpts, providers.WithClaudeCLISecurityHooks("", true, configuredShellDenyPatterns(cfg))) if gatewayAddr != "" { mcpData := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken) mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore) cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData)) } registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) slog.Info("registered provider from DB", "name", p.Name) return true } // registerACPFromConfig registers an ACP provider from config file settings. func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig, shellDenyGroups map[string]bool) { if _, err := exec.LookPath(cfg.Binary); err != nil { slog.Warn("acp: binary not found, skipping", "binary", cfg.Binary, "error", err) return } idleTTL := 5 * time.Minute if cfg.IdleTTL != "" { if d, err := time.ParseDuration(cfg.IdleTTL); err == nil { idleTTL = d } } workDir := cfg.WorkDir if workDir == "" { workDir = defaultACPWorkDir() } var opts []providers.ACPOption if cfg.Model != "" { opts = append(opts, providers.WithACPModel(cfg.Model)) } if cfg.PermMode != "" { opts = append(opts, providers.WithACPPermMode(cfg.PermMode)) } registry.Register(providers.NewACPProvider( cfg.Binary, cfg.Args, workDir, idleTTL, tools.ResolveDenyPatterns(shellDenyGroups), opts..., )) slog.Info("registered provider", "name", "acp", "binary", cfg.Binary) } // registerACPFromDB registers an ACP provider from a DB provider row. func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData, shellDenyGroups map[string]bool) { binary := p.APIBase // repurpose api_base as binary path if binary == "" { slog.Warn("acp: no binary specified in DB provider", "name", p.Name) return } if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) { slog.Warn("security.acp: invalid binary path from DB", "path", binary) return } if _, err := exec.LookPath(binary); err != nil { slog.Warn("acp: binary not found, skipping", "binary", binary, "error", err) return } // Parse settings JSONB for extra config var settings struct { Args []string `json:"args"` IdleTTL string `json:"idle_ttl"` PermMode string `json:"perm_mode"` WorkDir string `json:"work_dir"` } if p.Settings != nil { if err := json.Unmarshal(p.Settings, &settings); err != nil { slog.Warn("acp: invalid settings JSON, using defaults", "name", p.Name, "error", err) } } idleTTL := 5 * time.Minute if settings.IdleTTL != "" { if d, err := time.ParseDuration(settings.IdleTTL); err == nil { idleTTL = d } } workDir := settings.WorkDir if workDir == "" { workDir = defaultACPWorkDir() } registry.RegisterForTenant(p.TenantID, providers.NewACPProvider( binary, settings.Args, workDir, idleTTL, tools.ResolveDenyPatterns(shellDenyGroups), providers.WithACPName(p.Name), providers.WithACPModel(p.Name), )) slog.Info("registered provider from DB", "name", p.Name, "type", "acp") } func configuredShellDenyGroups(cfg *config.Config) map[string]bool { if cfg == nil { return nil } return cfg.ShellDenyGroupsSnapshot() } func configuredShellDenyPatterns(cfg *config.Config) []*regexp.Regexp { return tools.ResolveDenyPatterns(configuredShellDenyGroups(cfg)) } // defaultACPWorkDir returns the default workspace directory for ACP agents. func defaultACPWorkDir() string { return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces") }