package cmd import ( "context" "log/slog" "os" "time" "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/edition" "github.com/nextlevelbuilder/goclaw/internal/gateway/methods" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/internal/tools" "github.com/nextlevelbuilder/goclaw/internal/webhooks" ) // httpHandlers bundles the results of wireHTTP() for passing to wireHTTPHandlersOnServer. type httpHandlers struct { agents *httpapi.AgentsHandler skills *httpapi.SkillsHandler traces *httpapi.TracesHandler mcp *httpapi.MCPHandler channelInstances *httpapi.ChannelInstancesHandler providers *httpapi.ProvidersHandler builtinTools *httpapi.BuiltinToolsHandler pendingMessages *httpapi.PendingMessagesHandler teamEvents *httpapi.TeamEventsHandler secureCLI *httpapi.SecureCLIHandler secureCLIGrant *httpapi.SecureCLIGrantHandler mcpUserCreds *httpapi.MCPUserCredentialsHandler mcpOAuth *httpapi.MCPOAuthHandler } // wireHTTPHandlersOnServer registers all HTTP handler objects onto the gateway server. // Called after wireHTTP() and wireExtras() have returned their results. func (d *gatewayDeps) wireHTTPHandlersOnServer( h httpHandlers, wakeH *httpapi.WakeHandler, mcpPool *mcpbridge.Pool, postTurn tools.PostTurnProcessor, mediaStore *media.Store, ) { if h.providers != nil { h.providers.SetAPIBaseFallback(d.cfg.Providers.APIBaseForType) } if h.agents != nil { d.server.SetAgentsHandler(h.agents) } if h.skills != nil { d.server.SetSkillsHandler(h.skills) } if h.traces != nil { d.server.SetTracesHandler(h.traces) } // External wake/trigger API — wakeH was created by caller before invoking this method. d.server.SetWakeHandler(wakeH) if h.mcp != nil { if mcpPool != nil { h.mcp.SetPoolEvictor(mcpPool) } d.server.SetMCPHandler(h.mcp) } if h.mcpUserCreds != nil { d.server.SetMCPUserCredentialsHandler(h.mcpUserCreds) } if h.mcpOAuth != nil { if mcpPool != nil { h.mcpOAuth.SetEvictor(mcpPool) } d.server.SetMCPOAuthHandler(h.mcpOAuth) } if h.channelInstances != nil { d.server.SetChannelInstancesHandler(h.channelInstances) } if h.providers != nil { d.server.SetProvidersHandler(h.providers) } if h.teamEvents != nil { d.server.SetTeamEventsHandler(h.teamEvents) } if d.pgStores != nil && d.pgStores.Teams != nil { d.server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(d.pgStores.Teams, d.workspace)) d.server.SetWorkspaceUploadHandler(httpapi.NewWorkspaceUploadHandler(d.pgStores.Teams, d.workspace, d.msgBus)) } if h.builtinTools != nil { d.server.SetBuiltinToolsHandler(h.builtinTools) } if h.pendingMessages != nil { if pc := d.cfg.Channels.PendingCompaction; pc != nil { h.pendingMessages.SetKeepRecent(pc.KeepRecent) h.pendingMessages.SetMaxTokens(pc.MaxTokens) h.pendingMessages.SetProviderModel(pc.Provider, pc.Model) } d.server.SetPendingMessagesHandler(h.pendingMessages) } if h.secureCLI != nil { d.server.SetSecureCLIHandler(h.secureCLI) } if h.secureCLIGrant != nil { d.server.SetSecureCLIGrantHandler(h.secureCLIGrant) } if d.pgStores != nil && d.pgStores.BrowserCookies != nil { d.server.SetBrowserCookiesHandler(httpapi.NewBrowserCookiesHandler(d.pgStores.BrowserCookies)) } // Activity audit log API if d.pgStores.Activity != nil { d.server.SetActivityHandler(httpapi.NewActivityHandler(d.pgStores.Activity)) } // System configs API if d.pgStores.SystemConfigs != nil { d.server.SetSystemConfigsHandler(httpapi.NewSystemConfigsHandler(d.pgStores.SystemConfigs, d.msgBus)) // Refresh in-memory config when system_configs change via HTTP API d.msgBus.Subscribe(bus.TopicSystemConfigChanged, func(evt bus.Event) { // Use tenant context from the request that triggered the change ctx := context.Background() if reqCtx, ok := evt.Payload.(context.Context); ok { ctx = reqCtx } else { ctx = store.WithTenantID(ctx, store.MasterTenantID) } if sysConfigs, err := d.pgStores.SystemConfigs.List(ctx); err == nil && len(sysConfigs) > 0 { d.cfg.ApplySystemConfigs(sysConfigs) // Update PGMemoryStore chunk config so new documents use updated settings if mem := d.cfg.Agents.Defaults.Memory; mem != nil { if pgMem, ok := d.pgStores.Memory.(*pg.PGMemoryStore); ok { pgMem.UpdateChunkConfig(mem.MaxChunkLen, mem.ChunkOverlap) } } // Note: vault enrichment provider is resolved per-tenant at runtime, // no hot-reload needed here slog.Debug("system_configs refreshed to in-memory config", "keys", len(sysConfigs)) } }) } d.server.SetBrandingAssetsHandler(httpapi.NewBrandingAssetsHandler(d.dataDir)) // Usage analytics API if d.pgStores.Snapshots != nil { d.server.SetUsageHandler(httpapi.NewUsageHandler(d.pgStores.Snapshots, d.pgStores.UsageEvents, d.pgStores.DB)) } if d.pgStores.UsageCaps != nil { d.server.SetUsageCapsHandler(httpapi.NewUsageCapsHandler(d.pgStores.UsageCaps, d.pgStores.Tenants)) } // Runtime package management (install/uninstall system/pip/npm/github packages) // Wire the update registry AFTER initGitHubInstaller so DefaultGitHubInstaller() is set. initGitHubInstaller() pkgHandler := wirePackagesHandler(d) d.server.SetPackagesHandler(pkgHandler) d.server.SetGatewayUpgradeHandler(httpapi.NewGatewayUpgradeHandlerFromEnv()) // API documentation (OpenAPI spec + Swagger UI at /docs) d.server.SetDocsHandler(httpapi.NewDocsHandler()) // Edition info (public, no auth — used by desktop UI comparison modal) d.server.SetEditionHandler(httpapi.NewEditionHandler()) if d.pgStores != nil && d.pgStores.APIKeys != nil { d.server.SetAPIKeysHandler(httpapi.NewAPIKeysHandler(d.pgStores.APIKeys, d.msgBus)) d.server.SetAPIKeyStore(d.pgStores.APIKeys) httpapi.InitAPIKeyCache(d.pgStores.APIKeys, d.msgBus) } // K10: single shared webhookLimiter — one per process enforces per-tenant RPM cap across // both LLM and message endpoints. Two separate instances would double the effective cap. webhookEncKey := os.Getenv("GOCLAW_ENCRYPTION_KEY") // K6: refuse to mount any webhook handler when GOCLAW_ENCRYPTION_KEY is unset. // crypto.Encrypt("", "") returns plaintext unchanged, so an empty key would silently // persist raw secrets to the database — defeating the stated DB-leak protection. // Skip-mount approach: process still starts (all other subsystems work), but // /v1/webhooks/* returns 404. Set GOCLAW_ENCRYPTION_KEY to re-enable webhooks. if webhookEncKey == "" { slog.Error("webhook subsystem disabled: GOCLAW_ENCRYPTION_KEY not set. Set the env var to enable /v1/webhooks/* endpoints.") } else { sharedWebhookLimiter := httpapi.NewWebhookLimiter() // Webhook admin CRUD — available in all editions (Standard + Lite). // Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06. // adminH is captured so the test endpoint (POST /v1/webhooks/{id}/test) can be wired // with the runtime invokers (llm/message handlers) once they are constructed below. var adminH *httpapi.WebhooksAdminHandler if d.pgStores != nil && d.pgStores.Webhooks != nil { adminH = httpapi.NewWebhooksAdminHandler( d.pgStores.Webhooks, d.pgStores.WebhookCalls, d.pgStores.Tenants, d.msgBus, ) adminH.SetEncKey(webhookEncKey) d.server.SetWebhooksAdminHandler(adminH) } // Webhook message endpoint — Standard edition only (channels required). // Phase 05b: POST /v1/webhooks/message → sync channel send (text + optional media). var msgH *httpapi.WebhookMessageHandler if edition.Current().AllowsChannels() && d.pgStores != nil && d.pgStores.Webhooks != nil && d.pgStores.WebhookCalls != nil && d.pgStores.ChannelInstances != nil && d.channelMgr != nil { msgH = httpapi.NewWebhookMessageHandler( d.channelMgr, d.pgStores.ChannelInstances, d.pgStores.WebhookCalls, d.pgStores.Webhooks, sharedWebhookLimiter, // K10: shared limiter ) msgH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time d.server.SetWebhookMessageHandler(msgH) } // Webhook LLM endpoint — all editions (Standard + Lite). // Phase 06: POST /v1/webhooks/llm → sync agent run (≤30s) or async enqueue. // LocalhostOnly enforcement is handled by WebhookAuthMiddleware at request time. // lane=nil → handler self-creates internal default lane (4-slot). var llmH *httpapi.WebhookLLMHandler if d.pgStores != nil && d.pgStores.Webhooks != nil && d.pgStores.WebhookCalls != nil && d.agentRouter != nil { llmH = httpapi.NewWebhookLLMHandler( d.agentRouter, d.pgStores.WebhookCalls, d.pgStores.Webhooks, sharedWebhookLimiter, // K10: shared limiter nil, // lane: nil → internal default (4-slot); configurable in future via cfg webhooks.ResolveTimeoutSec(d.cfg.Gateway.WebhookSyncTimeoutSec), webhooks.ResolveStream(d.cfg.Gateway.WebhookStream), ) llmH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time d.server.SetWebhookLLMHandler(llmH) } // Wire the admin test endpoint with runtime invokers. msgH is nil on Lite — the // admin handler guards on nil and rejects message tests there. if adminH != nil { adminH.SetTesters(llmH, msgH) } } // Allow browser-paired users to access HTTP APIs if d.pgStores.Pairing != nil { httpapi.InitPairingAuth(d.pgStores.Pairing) } // Memory management API if d.pgStores != nil && d.pgStores.Memory != nil { d.server.SetMemoryHandler(httpapi.NewMemoryHandler(d.pgStores.Memory, d.pgStores.Agents)) } // Knowledge graph API if d.pgStores != nil && d.pgStores.KnowledgeGraph != nil { kgHandler := httpapi.NewKnowledgeGraphHandler(d.pgStores.KnowledgeGraph, d.providerRegistry) kgHandler.SetUsageCapService(d.usageCapSvc) d.server.SetKnowledgeGraphHandler(kgHandler) } // V3: Evolution metrics + suggestions API if d.pgStores != nil && d.pgStores.EvolutionMetrics != nil && d.pgStores.EvolutionSuggestions != nil { var evoOpts []httpapi.EvolutionHandlerOpt if manageStore, ok := d.pgStores.Skills.(store.SkillManageStore); ok && d.skillsLoader != nil { evoOpts = append(evoOpts, httpapi.WithSkillCreation(manageStore, d.skillsLoader, d.dataDir)) } if d.pgStores.Agents != nil { evoOpts = append(evoOpts, httpapi.WithAgentStore(d.pgStores.Agents)) } if d.pgStores.BuiltinToolTenantCfgs != nil { evoOpts = append(evoOpts, httpapi.WithToolTenantCfgs(d.pgStores.BuiltinToolTenantCfgs)) } d.server.SetEvolutionHandler(httpapi.NewEvolutionHandler(d.pgStores.EvolutionMetrics, d.pgStores.EvolutionSuggestions, evoOpts...)) } // V3: Knowledge Vault document API if d.pgStores != nil && d.pgStores.Vault != nil { vh := httpapi.NewVaultHandler(d.pgStores.Vault, d.pgStores.Teams, d.workspace, d.domainBus, d.pgStores.Agents, d.pgStores.Teams) vh.SetEnrichProgress(d.enrichProgress) vh.SetEnrichWorker(d.enrichWorker) d.server.SetVaultHandler(vh) // Lightweight graph visualization endpoints (vault + KG). var kgGraph store.KGGraphStore if d.pgStores.KnowledgeGraph != nil { kgGraph = newKGGraphStore(d.pgStores.DB) } vgHandler := httpapi.NewVaultGraphHandler( newVaultGraphStore(d.pgStores.DB), kgGraph, d.pgStores.Teams, ) d.server.SetVaultGraphHandler(vgHandler) } // V3: Episodic memory summaries API if d.pgStores != nil && d.pgStores.Episodic != nil { d.server.SetEpisodicHandler(httpapi.NewEpisodicHandler(d.pgStores.Episodic)) } // V3: Orchestration mode API (read-only) if d.pgStores != nil && d.pgStores.Agents != nil { d.server.SetOrchestrationHandler(httpapi.NewOrchestrationHandler(d.pgStores.Agents, d.pgStores.Teams, d.pgStores.AgentLinks)) } // V3: Per-agent v3 feature flags API if d.pgStores != nil && d.pgStores.Agents != nil { d.server.SetV3FlagsHandler(httpapi.NewV3FlagsHandler(d.pgStores.Agents)) } // Workspace file serving endpoint — serves files by absolute path, auth-token protected. d.server.SetFilesHandler(httpapi.NewFilesHandler(d.workspace, d.dataDir)) // Storage file management — browse/delete files under the resolved workspace directory. d.server.SetStorageHandler(httpapi.NewStorageHandler(d.workspace, d.pgStores.Tenants)) // Media upload endpoint — accepts multipart file uploads, returns temp path + MIME type. d.server.SetMediaUploadHandler(httpapi.NewMediaUploadHandler()) // Media serve endpoint — serves persisted media files by ID for WS/web clients. if mediaStore != nil { d.server.SetMediaServeHandler(httpapi.NewMediaServeHandler(mediaStore)) } // ElevenLabs voice list + refresh endpoints (GET /v1/voices, POST /v1/voices/refresh). // VoiceCache is shared between the HTTP handler and the WS voices.list method. // TTL 1h + LRU cap 1000 tenants. { voiceCache := audio.NewVoiceCache(1*time.Hour, 1000) var secretStore store.ConfigSecretsStore if d.pgStores != nil && d.pgStores.ConfigSecrets != nil { secretStore = d.pgStores.ConfigSecrets } var tenantStore store.TenantStore if d.pgStores != nil && d.pgStores.Tenants != nil { tenantStore = d.pgStores.Tenants } voicesH := httpapi.NewVoicesHandler(voiceCache, secretStore, tenantStore) d.server.SetVoicesHandler(voicesH) // Wire WS method — provider nil means each request resolves key via secretStore at HTTP layer. // For WS, use same cache. Provider is resolved via secretStore at WS level in a future phase. methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router()) // Wire the same cache + secret store into the CRUD MCP server (see // internal/mcp/crud_server.go, mounted at /api/mcp/ in BuildMux()). d.server.SetVoiceCache(voiceCache, secretStore) } // TTS synthesize endpoint — shares audio.Manager with setupTTS. if d.audioMgr != nil { ttsH := httpapi.NewTTSHandler(d.audioMgr) // Reuse the server's rate limiter (per-IP/token; NOT per-user). // Server.RateLimiter() is non-nil by construction (server.go:104). if rl := d.server.RateLimiter(); rl != nil && rl.Enabled() { ttsH.SetRateLimiter(rl.Allow) } // Wire stores for per-tenant TTS config lookup at synthesis time. if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil { ttsH.SetStores(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets) // Wire tenant resolver for channels TTS auto-apply d.audioMgr.SetTenantResolver(httpapi.NewTenantTTSResolver(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets)) } d.server.SetTTSHandler(ttsH) d.ttsHandler = ttsH // store for hot-reload } // Per-tenant TTS config endpoint — allows tenant admins to configure TTS. if d.pgStores.SystemConfigs != nil && d.pgStores.ConfigSecrets != nil { d.server.SetTTSConfigHandler(httpapi.NewTTSConfigHandler(d.pgStores.SystemConfigs, d.pgStores.ConfigSecrets, d.pgStores.Tenants)) } // Workstations API — Standard edition only. // Lite edition MUST NOT expose these routes (silent orphan data + contract violation). if edition.Current().Name != "lite" { if d.pgStores != nil && d.pgStores.Workstations != nil && d.pgStores.WorkstationLinks != nil { wsH := httpapi.NewWorkstationsHandler( d.pgStores.Workstations, d.pgStores.WorkstationLinks, d.pgStores.Tenants, ) if d.pgStores.WorkstationPermissions != nil { wsH.SetPermStore(d.pgStores.WorkstationPermissions) } if d.pgStores.WorkstationActivity != nil { wsH.SetActivityStore(d.pgStores.WorkstationActivity) } d.server.SetWorkstationsHandler(wsH) } } // Seed + apply builtin tool disables if d.pgStores.BuiltinTools != nil { seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools) migrateBuiltinToolSettings(context.Background(), d.pgStores.BuiltinTools) backfillWebFetchSettings(context.Background(), d.pgStores.BuiltinTools) applyBuiltinToolDisables(context.Background(), d.pgStores.BuiltinTools, d.toolsReg) } }