Files
Goon 591d809779 Merge remote-tracking branch 'upstream/dev' into dev
# Conflicts:
#	internal/cron/service.go
2026-06-15 14:14:16 +07:00

835 lines
32 KiB
Go

package http
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/oauth"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/security"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ProvidersHandler handles LLM provider CRUD endpoints.
type ProvidersHandler struct {
store store.ProviderStore
secretStore store.ConfigSecretsStore
providerReg *providers.Registry
gatewayAddr string // for injecting MCP bridge into Claude CLI providers
mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers
shellDenyGroups func() map[string]bool // optional: current global shell deny-group overrides
apiBaseFallback func(providerType string) string // optional: config/env fallback for api_base
cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates
msgBus *bus.MessageBus
sysConfigStore store.SystemConfigStore
tracingStore store.TracingStore // optional: for provider-scoped pool activity
agents store.AgentCRUDStore // optional: for provider pool activity agent lookup
modelReg providers.ModelRegistry // optional: forward-compat model resolver for Anthropic
usageCaps *usagecaps.Service
}
// NewProvidersHandler creates a handler for provider management endpoints.
func NewProvidersHandler(s store.ProviderStore, secretStore store.ConfigSecretsStore, providerReg *providers.Registry, gatewayAddr string) *ProvidersHandler {
return &ProvidersHandler{store: s, secretStore: secretStore, providerReg: providerReg, gatewayAddr: gatewayAddr}
}
// SetMessageBus sets the message bus for audit event broadcasting.
// Must be called before serving requests (not thread-safe).
func (h *ProvidersHandler) SetMessageBus(msgBus *bus.MessageBus) {
h.msgBus = msgBus
}
// SetSystemConfigStore sets the system config store for embedding status checks.
func (h *ProvidersHandler) SetSystemConfigStore(s store.SystemConfigStore) {
h.sysConfigStore = s
}
// SetMCPServerLookup sets the per-agent MCP server lookup for Claude CLI providers.
// Must be called before serving requests (not thread-safe).
func (h *ProvidersHandler) SetMCPServerLookup(lookup providers.MCPServerLookup) {
h.mcpLookup = lookup
}
// SetShellDenyGroupsSource sets the current global shell deny-group source for
// runtime provider registration. Must be called before serving requests.
func (h *ProvidersHandler) SetShellDenyGroupsSource(fn func() map[string]bool) {
h.shellDenyGroups = fn
}
// SetAPIBaseFallback sets a function that returns config/env api_base by provider type.
// Used as fallback when DB providers have no api_base set.
func (h *ProvidersHandler) SetAPIBaseFallback(fn func(providerType string) string) {
h.apiBaseFallback = fn
}
// SetTracingStore sets the tracing store for provider-scoped pool activity.
func (h *ProvidersHandler) SetTracingStore(ts store.TracingStore) {
h.tracingStore = ts
}
// SetAgentStore sets the agent store for provider pool activity agent lookup.
func (h *ProvidersHandler) SetAgentStore(as store.AgentCRUDStore) {
h.agents = as
}
// SetModelRegistry sets the forward-compat model registry used by Anthropic providers
// for model alias resolution and token counting. Must be called before serving requests.
func (h *ProvidersHandler) SetModelRegistry(r providers.ModelRegistry) {
h.modelReg = r
}
func (h *ProvidersHandler) SetUsageCapService(s *usagecaps.Service) {
h.usageCaps = s
}
func (h *ProvidersHandler) currentShellDenyPatterns() []*regexp.Regexp {
if h.shellDenyGroups == nil {
return tools.DefaultDenyPatterns()
}
return tools.ResolveDenyPatterns(h.shellDenyGroups())
}
// resolveAPIBase returns the provider's api_base, falling back to config/env if empty.
// For Ollama/OllamaCloud providers, applies a safety-net normalization: if the stored
// value is missing the /v1 suffix (pre-existing record before write-time normalization),
// the suffix is appended so all downstream call sites receive a ready-to-use URL.
func (h *ProvidersHandler) resolveAPIBase(p *store.LLMProviderData) string {
base := ""
if p.APIBase != "" {
base = p.APIBase
} else if h.apiBaseFallback != nil {
base = h.apiBaseFallback(p.ProviderType)
}
// Safety net: normalize Ollama URLs missing /v1 (pre-existing DB records).
if base != "" && (p.ProviderType == store.ProviderOllama || p.ProviderType == store.ProviderOllamaCloud) {
base = strings.TrimRight(base, "/")
if !strings.HasSuffix(base, "/v1") {
base += "/v1"
}
}
return base
}
// emitProviderCacheInvalidate broadcasts a provider cache invalidation event.
// Subscribers (e.g. ACP re-registration in gateway_managed.go) react to reload from DB.
func (h *ProvidersHandler) emitProviderCacheInvalidate(ctx context.Context, tenantID uuid.UUID, name string) bool {
if h.msgBus == nil {
return false
}
tenantID = providerCacheTenantID(ctx, tenantID)
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
TenantID: tenantID,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindProvider, Key: name, TenantID: tenantID},
})
return true
}
func providerCacheTenantID(ctx context.Context, tenantID uuid.UUID) uuid.UUID {
if tenantID != uuid.Nil {
return tenantID
}
if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil {
return tid
}
return store.MasterTenantID
}
// RegisterRoutes registers all provider management routes on the given mux.
func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) {
// Provider CRUD
mux.HandleFunc("GET /v1/providers", h.auth(h.handleListProviders))
mux.HandleFunc("POST /v1/providers", h.auth(h.handleCreateProvider))
mux.HandleFunc("GET /v1/providers/{id}", h.auth(h.handleGetProvider))
mux.HandleFunc("PUT /v1/providers/{id}", h.auth(h.handleUpdateProvider))
mux.HandleFunc("DELETE /v1/providers/{id}", h.auth(h.handleDeleteProvider))
// Model listing (proxied to upstream provider API)
mux.HandleFunc("GET /v1/providers/{id}/models", h.auth(h.handleListProviderModels))
// Provider + model verification (pre-flight check)
mux.HandleFunc("POST /v1/providers/{id}/reconnect", h.auth(h.handleReconnectProvider))
mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider))
mux.HandleFunc("POST /v1/providers/{id}/verify-embedding", h.auth(h.handleVerifyEmbedding))
// Provider-scoped Codex pool activity monitor
mux.HandleFunc("GET /v1/providers/{id}/codex-pool-activity", h.auth(h.handleProviderCodexPoolActivity))
// Embedding system status
mux.HandleFunc("GET /v1/embedding/status", h.auth(h.handleEmbeddingStatus))
// Claude CLI auth status (global — not per-provider)
mux.HandleFunc("GET /v1/providers/claude-cli/auth-status", h.auth(h.handleClaudeCLIAuthStatus))
}
func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc {
return requireAuth(permissions.RoleAdmin, next)
}
// maskAPIKey replaces non-empty API keys with "***".
func maskAPIKey(p *store.LLMProviderData) {
if p.APIKey != "" {
p.APIKey = "***"
}
}
type providerRuntimeRegistrationStatus string
const (
providerRuntimeRegistered providerRuntimeRegistrationStatus = "registered"
providerRuntimeDisabled providerRuntimeRegistrationStatus = "disabled"
providerRuntimeSkipped providerRuntimeRegistrationStatus = "skipped"
providerRuntimeMissingCredential providerRuntimeRegistrationStatus = "missing_credential"
providerRuntimeInvalidConfig providerRuntimeRegistrationStatus = "invalid_config"
)
// registerInMemory adds (or replaces) a provider in the in-memory registry
// so it's immediately usable for verify/chat without a gateway restart.
func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) providerRuntimeRegistrationStatus {
if h.providerReg == nil || !p.Enabled {
if p.Enabled {
return providerRuntimeSkipped
}
return providerRuntimeDisabled
}
// ACP agents don't need an API key — skip in-memory registration
// (ACP providers are registered via gateway_providers.go on startup or restart)
if p.ProviderType == store.ProviderACP {
return providerRuntimeSkipped
}
// Claude CLI doesn't need an API key — register immediately
if p.ProviderType == store.ProviderClaudeCLI {
cliPath := p.APIBase // reuse APIBase field for CLI path
if cliPath == "" {
cliPath = "claude"
}
// Validate: only accept "claude" or absolute path (mirrors startup path in cmd/gateway_providers.go).
// Prevents DB-poisoning attacks where a relative path resolves against CWD.
if cliPath != "claude" && !filepath.IsAbs(cliPath) {
slog.Warn("security.claude_cli: invalid path, using default", "path", cliPath, "provider", p.Name)
cliPath = "claude"
}
if _, err := exec.LookPath(cliPath); err != nil {
slog.Warn("claude-cli: binary not found, skipping in-memory registration", "path", cliPath, "provider", p.Name, "error", err)
return providerRuntimeInvalidConfig
}
cliOpts := []providers.ClaudeCLIOption{
providers.WithClaudeCLIName(p.Name),
providers.WithClaudeCLISecurityHooks("", true, h.currentShellDenyPatterns()),
}
if h.gatewayAddr != "" {
mcpData := providers.BuildCLIMCPConfigData(nil, h.gatewayAddr, pkgGatewayToken)
mcpData.AgentMCPLookup = h.mcpLookup
cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData))
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...))
return providerRuntimeRegistered
}
// Ollama doesn't need an API key — handle before the key guard (same as startup).
// In Docker, swap localhost → host.docker.internal so the container can reach the host.
// 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/v1"
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", config.DockerLocalhost(host), "llama3.3"))
return providerRuntimeRegistered
}
// 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, cannot register", "name", p.Name)
return providerRuntimeInvalidConfig
}
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: register in-memory failed", "name", p.Name, "error", err)
return providerRuntimeInvalidConfig
}
h.providerReg.RegisterForTenant(p.TenantID, prov)
return providerRuntimeRegistered
}
if p.APIKey == "" {
return providerRuntimeMissingCredential
}
apiBase := h.resolveAPIBase(p)
switch p.ProviderType {
case store.ProviderChatGPTOAuth:
ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID)
codex := providers.NewCodexProvider(p.Name, ts, apiBase, "")
if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil {
codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames)
}
h.providerReg.RegisterForTenant(p.TenantID, codex)
case store.ProviderAnthropicNative:
anthOpts := []providers.AnthropicOption{
providers.WithAnthropicName(p.Name),
providers.WithAnthropicBaseURL(apiBase),
}
if h.modelReg != nil {
anthOpts = append(anthOpts, providers.WithAnthropicRegistry(h.modelReg))
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, anthOpts...))
case store.ProviderDashScope:
h.providerReg.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, ""))
case store.ProviderBailian:
base := apiBase
if base == "" {
base = "https://coding-intl.dashscope.aliyuncs.com/v1"
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus"))
case store.ProviderNovita:
base := apiBase
if base == "" {
base = store.NovitaDefaultAPIBase
}
h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, store.NovitaDefaultModel))
case store.ProviderKimiCoding:
// Moonshot Kimi Coding requires a fixed User-Agent on every request.
base := 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,
})
h.providerReg.RegisterForTenant(p.TenantID, prov)
default:
prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "")
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
}
h.providerReg.RegisterForTenant(p.TenantID, prov)
}
return providerRuntimeRegistered
}
// normalizeOllamaAPIBase ensures Ollama and OllamaCloud api_base values include the
// /v1 suffix required for OpenAI-compatible endpoints. Normalizing at write time means
// resolveAPIBase() always returns a ready-to-use base URL.
func normalizeOllamaAPIBase(p *store.LLMProviderData) {
if p.ProviderType != store.ProviderOllama && p.ProviderType != store.ProviderOllamaCloud {
return
}
if p.APIBase == "" {
return
}
p.APIBase = strings.TrimRight(p.APIBase, "/")
if !strings.HasSuffix(p.APIBase, "/v1") {
p.APIBase += "/v1"
}
}
// localURLProviderTypes are provider types that legitimately run on localhost.
// They are restricted to an explicit localhost allowlist
// rather than skipping SSRF validation entirely.
var localURLProviderTypes = map[string]bool{
store.ProviderOllama: true,
store.ProviderACP: true,
}
// allowedLocalHosts are the only hosts permitted for local provider types.
// Explicit allowlist (not blocklist) to prevent new internal addresses from
// slipping through (e.g. 169.254.169.254 via ollama base URL).
var allowedLocalHosts = []string{"localhost", "127.0.0.1", "::1", "host.docker.internal"}
// dnsResolverFn resolves hostnames to IPs. Replaceable in tests.
var dnsResolverFn = net.LookupHost
// allowPrivateProviderURLsFn reports whether the operator has opted in to
// permitting private / loopback / link-local / internal-hostname provider base
// URLs via GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS. Evaluated once at first call so
// tests can override the variable before that happens.
var allowPrivateProviderURLsFn = sync.OnceValue(func() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS")))
return v == "1" || v == "true" || v == "yes"
})
// validateProviderURL rejects provider base URLs pointing to internal/private networks.
// Defense-in-depth: prevents SSRF when providers are later used for API calls.
//
// Logic:
// 1. Empty URL → allowed (provider may not need a custom base).
// 2. Claude CLI → api_base is an executable path/command, not a URL.
// 3. Scheme check (http/https only) → enforced for URL-based types, including
// local URL types. Blocks file://, gopher://, dict://, etc.
// 4. Local URL types (ollama, acp) → host must be in allowedLocalHosts
// (explicit allowlist prevents reaching 169.254.169.254 or internal services
// via the local-type bypass).
// 5. Remote types → if GOCLAW_ALLOW_PRIVATE_PROVIDER_URLS is set, allow and log.
// Otherwise: resolve DNS hostname; reject if ANY resolved IP satisfies
// security.IsBlocked (covers loopback, link-local, private, multicast,
// unspecified — including 0.0.0.0 and :: that earlier hand-rolled checks missed).
//
// DNS resolution on step 5 closes the nip.io / sslip.io / attacker-domain bypass
// where a hostname passes a literal-string blocklist but resolves to a private IP.
func validateProviderURL(rawURL string, providerType string) error {
if rawURL == "" {
return nil
}
if providerType == store.ProviderClaudeCLI {
return validateClaudeCLIExecutablePath(rawURL)
}
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Scheme check is unconditional for URL-based provider types, including local URL types.
switch u.Scheme {
case "http", "https":
default:
return fmt.Errorf("provider URL must use http or https scheme, got %q", u.Scheme)
}
host := u.Hostname()
// Local provider types: only allow an explicit localhost allowlist.
// This prevents using the local-type escape hatch to reach internal services
// or cloud metadata endpoints.
if localURLProviderTypes[providerType] {
for _, a := range allowedLocalHosts {
if strings.EqualFold(host, a) {
return nil
}
}
slog.Warn("security.provider_url.local_type_denied", "host", host, "provider_type", providerType)
return fmt.Errorf("provider type %q only allows localhost URLs (localhost, 127.0.0.1, ::1, host.docker.internal), got host %q", providerType, host)
}
// Operator opt-in to allow private-network provider URLs (e.g. LAN-hosted vLLM).
// Scheme check above still applies even with this gate open.
if allowPrivateProviderURLsFn() {
slog.Warn("security.provider_url.private_allowed", "host", host, "provider_type", providerType)
return nil
}
// Check literal IP first (avoids unnecessary DNS lookup).
if ip := net.ParseIP(host); ip != nil {
if security.IsBlocked(ip) {
slog.Warn("security.provider_url.blocked", "host", host, "provider_type", providerType)
return fmt.Errorf("provider URL cannot point to %s", host)
}
return nil
}
// Block .internal / .local suffix before DNS (fail-fast for well-known patterns).
if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {
slog.Warn("security.provider_url.blocked", "host", host, "provider_type", providerType)
return fmt.Errorf("provider URL cannot point to internal hostname: %s", host)
}
// Resolve DNS and check every returned address.
// Prevents bypass via wildcard services (nip.io, sslip.io) or attacker-controlled
// domains that map to private IPs (DNS-rebinding at config time).
addrs, err := dnsResolverFn(host)
if err != nil {
slog.Warn("security.provider_url.dns_resolve_failed", "host", host, "provider_type", providerType, "error", err)
return fmt.Errorf("provider URL hostname %q could not be resolved: %w", host, err)
}
for _, addr := range addrs {
ip := net.ParseIP(addr)
if ip == nil {
continue
}
if security.IsBlocked(ip) {
slog.Warn("security.provider_url.blocked_resolved", "host", host, "resolved_ip", ip.String(), "provider_type", providerType)
return fmt.Errorf("provider URL %q resolves to private/reserved address %s", host, ip)
}
}
return nil
}
func validateClaudeCLIExecutablePath(path string) error {
if strings.Contains(path, "\x00") {
return fmt.Errorf("Claude CLI executable path cannot contain NUL byte")
}
if _, err := url.ParseRequestURI(path); err == nil && strings.Contains(path, "://") {
return fmt.Errorf("Claude CLI api_base must be an executable path or %q, got URL %q", "claude", path)
}
if path == "claude" || filepath.IsAbs(path) {
return nil
}
return fmt.Errorf("Claude CLI api_base must be %q or an absolute executable path, got %q", "claude", path)
}
// --- Provider CRUD ---
func (h *ProvidersHandler) handleListProviders(w http.ResponseWriter, r *http.Request) {
providers, err := h.store.ListProviders(r.Context())
if err != nil {
slog.Error("providers.list", "error", err)
locale := extractLocale(r)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "providers")})
return
}
for i := range providers {
maskAPIKey(&providers[i])
}
publicProviders := make([]store.LLMProviderData, 0, len(providers))
for i := range providers {
publicProviders = append(publicProviders, canonicalizeProviderForResponse(&providers[i]))
}
writeJSON(w, http.StatusOK, map[string]any{"providers": publicProviders})
}
func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
var p store.LLMProviderData
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if p.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")})
return
}
if !isValidSlug(p.Name) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
if !store.ValidProviderTypes[p.ProviderType] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "unsupported provider_type")})
return
}
// Only one Claude CLI provider is allowed per instance (1 machine = 1 auth session).
// Mutex serializes check+create to prevent TOCTOU race.
if p.ProviderType == store.ProviderClaudeCLI {
h.cliMu.Lock()
defer h.cliMu.Unlock()
existing, _ := h.store.ListProviders(r.Context())
for _, ep := range existing {
if ep.ProviderType == store.ProviderClaudeCLI {
writeJSON(w, http.StatusConflict, map[string]string{
"error": i18n.T(locale, i18n.MsgAlreadyExists, "Claude CLI provider", "only one is allowed per instance"),
})
return
}
}
}
if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, uuid.Nil, &p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := validateProviderEmbeddingSettings(&p); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
if err := validateProviderURL(p.APIBase, p.ProviderType); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
// Normalize Ollama base URL to include /v1 so all code paths
// (chat, model listing, embedding verify) use the same value from DB.
normalizeOllamaAPIBase(&p)
if err := h.store.CreateProvider(r.Context(), &p); err != nil {
slog.Error("providers.create", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Register in-memory so verify/chat work without restart
h.registerInMemory(&p)
h.emitProviderCacheInvalidate(r.Context(), p.TenantID, p.Name)
emitAudit(h.msgBus, r, "provider.created", "provider", p.ID.String())
maskAPIKey(&p)
publicProvider := canonicalizeProviderForResponse(&p)
writeJSON(w, http.StatusCreated, publicProvider)
}
func (h *ProvidersHandler) handleGetProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
maskAPIKey(p)
publicProvider := canonicalizeProviderForResponse(p)
writeJSON(w, http.StatusOK, publicProvider)
}
func (h *ProvidersHandler) handleReconnectProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
var req struct {
Verify bool `json:"verify"`
}
if r.Body != nil && r.Body != http.NoBody && r.ContentLength != 0 {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
if req.Verify {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "verify is not supported by reconnect; call provider verify separately")})
return
}
}
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
if h.providerReg != nil {
h.providerReg.UnregisterForTenant(p.TenantID, p.Name)
}
status := "disabled"
registryUpdated := false
if p.Enabled {
switch h.registerInMemory(p) {
case providerRuntimeRegistered:
status = "reconnected"
registryUpdated = true
default:
status = "not_registered"
}
}
cacheInvalidated := h.emitProviderCacheInvalidate(r.Context(), p.TenantID, p.Name)
emitAudit(h.msgBus, r, "provider.reconnected", "provider", p.ID.String())
maskAPIKey(p)
publicProvider := canonicalizeProviderForResponse(p)
writeJSON(w, http.StatusOK, map[string]any{
"status": status,
"provider": publicProvider,
"registry_updated": registryUpdated,
"cache_invalidated": cacheInvalidated,
})
}
func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
var updates map[string]any
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// Validate name if being updated
if name, ok := updates["name"]; ok {
if s, _ := name.(string); !isValidSlug(s) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")})
return
}
}
// Strip masked API key — don't overwrite real value with "***"
if apiKey, ok := updates["api_key"]; ok {
if s, _ := apiKey.(string); s == "***" || s == "" {
delete(updates, "api_key")
}
}
// Allowlist: only permit known provider columns.
updates = filterAllowedKeys(updates, providerAllowedFields)
currentProvider, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
candidate := *currentProvider
if name, ok := updates["name"].(string); ok && name != "" {
candidate.Name = name
}
if apiKey, ok := updates["api_key"].(string); ok {
candidate.APIKey = apiKey
}
if apiBase, ok := updates["api_base"].(string); ok {
candidate.APIBase = apiBase
}
if enabled, ok := updates["enabled"].(bool); ok {
candidate.Enabled = enabled
}
if displayName, ok := updates["display_name"].(string); ok {
candidate.DisplayName = displayName
}
if pt, ok := updates["provider_type"].(string); ok && pt != "" {
candidate.ProviderType = pt
}
if settings, ok := updates["settings"]; ok {
rawSettings, err := marshalJSONRaw(settings)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
candidate.Settings = rawSettings
}
// Re-validate URLs against the (possibly new) provider type.
// When provider_type changes, existing api_base must also pass validation
// for the new type — prevents SSRF via ACP→non-ACP type switch.
typeChanged := candidate.ProviderType != currentProvider.ProviderType
if apiBase, ok := updates["api_base"]; ok {
if s, _ := apiBase.(string); s != "" {
if err := validateProviderURL(s, candidate.ProviderType); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
}
} else if typeChanged && candidate.APIBase != "" {
if err := validateProviderURL(candidate.APIBase, candidate.ProviderType); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
}
if baseURL, ok := updates["base_url"]; ok {
if s, _ := baseURL.(string); s != "" {
if err := validateProviderURL(s, candidate.ProviderType); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
}
}
// Normalize Ollama base URL to include /v1 so all code paths use the same value.
normalizeOllamaAPIBase(&candidate)
if candidate.APIBase != currentProvider.APIBase {
updates["api_base"] = candidate.APIBase
}
if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, id, &candidate); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := validateProviderEmbeddingSettings(&candidate); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
return
}
// Track old name before update for registry cleanup
var oldName string
if h.providerReg != nil {
oldName = currentProvider.Name
}
if err := h.store.UpdateProvider(r.Context(), id, updates); err != nil {
slog.Error("providers.update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Sync in-memory registry with updated provider
if h.providerReg != nil {
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
// Unregister old name if renamed to prevent ghost entries
if oldName != "" && oldName != updated.Name {
h.providerReg.UnregisterForTenant(updated.TenantID, oldName)
}
if !updated.Enabled {
h.providerReg.UnregisterForTenant(updated.TenantID, updated.Name)
} else {
h.registerInMemory(updated)
}
}
}
// Notify subscribers (e.g. ACP re-registration) about the change
if updated, err := h.store.GetProvider(r.Context(), id); err == nil {
h.emitProviderCacheInvalidate(r.Context(), updated.TenantID, updated.Name)
if oldName != "" && oldName != updated.Name {
h.emitProviderCacheInvalidate(r.Context(), updated.TenantID, oldName)
}
}
emitAudit(h.msgBus, r, "provider.updated", "provider", id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
}
func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
// Read provider before deleting so we can unregister it
var providerName string
var providerTenantID uuid.UUID
if p, err := h.store.GetProvider(r.Context(), id); err == nil {
providerName = p.Name
providerTenantID = p.TenantID
}
if err := h.store.DeleteProvider(r.Context(), id); err != nil {
slog.Error("providers.delete", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if h.providerReg != nil && providerName != "" {
h.providerReg.UnregisterForTenant(providerTenantID, providerName)
}
if providerName != "" {
h.emitProviderCacheInvalidate(r.Context(), providerTenantID, providerName)
}
emitAudit(h.msgBus, r, "provider.deleted", "provider", id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}