mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 16:10:59 +00:00
843b550651
Runtime package management with security hardening: - pkg-helper: root-privileged daemon for apk install/uninstall via Unix socket - HTTP API: /v1/packages (list/install/uninstall/runtimes), admin role required for writes - Shell deny groups: 15 configurable groups (per-agent overrides via context) - Packages UI: Web page for managing system/pip/npm packages with confirmation dialogs - Docker: privilege separation (root entrypoint → su-exec drop), init for zombie reaping - Security: umask socket creation, persist file validation, deny pattern hardening (Node.js fetch/http, Python from/import, curl localhost, sensitive env vars) - Auth: empty gateway token → admin role (dev/single-user mode)
672 lines
22 KiB
Go
672 lines
22 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
|
mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// Server is the main gateway server handling WebSocket and HTTP connections.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
eventPub bus.EventPublisher
|
|
agents *agent.Router
|
|
sessions store.SessionStore
|
|
tools *tools.Registry
|
|
router *MethodRouter
|
|
|
|
policyEngine *permissions.PolicyEngine
|
|
pairingService store.PairingStore
|
|
agentsHandler *httpapi.AgentsHandler // agent CRUD API
|
|
skillsHandler *httpapi.SkillsHandler // skill management API
|
|
tracesHandler *httpapi.TracesHandler // LLM trace listing API
|
|
wakeHandler *httpapi.WakeHandler // external wake/trigger API
|
|
mcpHandler *httpapi.MCPHandler // MCP server management API
|
|
customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API
|
|
channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API
|
|
providersHandler *httpapi.ProvidersHandler // provider CRUD API
|
|
delegationsHandler *httpapi.DelegationsHandler // delegation history API
|
|
teamEventsHandler *httpapi.TeamEventsHandler // team event history API
|
|
builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API
|
|
pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API
|
|
secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API
|
|
packagesHandler *httpapi.PackagesHandler // runtime package management API
|
|
memoryHandler *httpapi.MemoryHandler // memory management API
|
|
kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API
|
|
oauthHandler *httpapi.OAuthHandler // OAuth endpoints
|
|
filesHandler *httpapi.FilesHandler // workspace file serving
|
|
storageHandler *httpapi.StorageHandler // storage file management
|
|
mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint
|
|
mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint
|
|
activityHandler *httpapi.ActivityHandler // activity audit log API
|
|
usageHandler *httpapi.UsageHandler // usage analytics API
|
|
apiKeysHandler *httpapi.APIKeysHandler // API key management
|
|
apiKeyStore store.APIKeyStore // for API key auth lookup
|
|
docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI
|
|
agentStore store.AgentStore // for context injection in tools_invoke
|
|
msgBus *bus.MessageBus // for MCP bridge media delivery
|
|
|
|
upgrader websocket.Upgrader
|
|
rateLimiter *RateLimiter
|
|
clients map[string]*Client
|
|
mu sync.RWMutex
|
|
|
|
startedAt time.Time
|
|
version string
|
|
db interface{ PingContext(context.Context) error } // for health check DB ping
|
|
|
|
logTee *LogTee // optional; auto-unsubscribes clients on disconnect
|
|
|
|
httpServer *http.Server
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
// NewServer creates a new gateway server.
|
|
func NewServer(cfg *config.Config, eventPub bus.EventPublisher, agents *agent.Router, sess store.SessionStore, toolsReg ...*tools.Registry) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
eventPub: eventPub,
|
|
agents: agents,
|
|
sessions: sess,
|
|
clients: make(map[string]*Client),
|
|
startedAt: time.Now(),
|
|
}
|
|
|
|
s.upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: s.checkOrigin,
|
|
}
|
|
|
|
if len(toolsReg) > 0 && toolsReg[0] != nil {
|
|
s.tools = toolsReg[0]
|
|
}
|
|
|
|
// Initialize rate limiter.
|
|
// rate_limit_rpm > 0 → enabled at that RPM
|
|
// rate_limit_rpm == 0 → disabled (default, backward compat)
|
|
// rate_limit_rpm < 0 → disabled explicitly
|
|
s.rateLimiter = NewRateLimiter(cfg.Gateway.RateLimitRPM, 5)
|
|
|
|
s.router = NewMethodRouter(s)
|
|
return s
|
|
}
|
|
|
|
// RateLimiter returns the server's rate limiter for use by method handlers.
|
|
func (s *Server) RateLimiter() *RateLimiter { return s.rateLimiter }
|
|
|
|
// checkOrigin validates WebSocket connection origin against the allowed origins whitelist.
|
|
// If no origins are configured, all origins are allowed (backward compatibility / dev mode).
|
|
// Empty Origin header (non-browser clients like CLI/SDK) is always allowed.
|
|
func (s *Server) checkOrigin(r *http.Request) bool {
|
|
allowed := s.cfg.Gateway.AllowedOrigins
|
|
if len(allowed) == 0 {
|
|
return true // no config = allow all (backward compat)
|
|
}
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return true // non-browser clients (CLI, SDK, channels)
|
|
}
|
|
for _, a := range allowed {
|
|
if origin == a || a == "*" {
|
|
return true
|
|
}
|
|
}
|
|
slog.Warn("security.cors_rejected", "origin", origin)
|
|
return false
|
|
}
|
|
|
|
// BuildMux creates and caches the HTTP mux with all routes registered.
|
|
// Call this before Start() if you need the mux for additional listeners (e.g. Tailscale).
|
|
func (s *Server) BuildMux() *http.ServeMux {
|
|
if s.mux != nil {
|
|
return s.mux
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
// WebSocket endpoint
|
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
|
|
|
// HTTP API endpoints
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
|
|
// OpenAI-compatible chat completions
|
|
isManaged := s.agentStore != nil
|
|
chatHandler := httpapi.NewChatCompletionsHandler(s.agents, s.sessions, s.cfg.Gateway.Token, isManaged)
|
|
if s.rateLimiter.Enabled() {
|
|
chatHandler.SetRateLimiter(s.rateLimiter.Allow)
|
|
}
|
|
mux.Handle("/v1/chat/completions", chatHandler)
|
|
|
|
// OpenResponses protocol
|
|
responsesHandler := httpapi.NewResponsesHandler(s.agents, s.sessions, s.cfg.Gateway.Token)
|
|
mux.Handle("/v1/responses", responsesHandler)
|
|
|
|
// Direct tool invocation
|
|
if s.tools != nil {
|
|
toolsHandler := httpapi.NewToolsInvokeHandler(s.tools, s.cfg.Gateway.Token, s.agentStore)
|
|
mux.Handle("/v1/tools/invoke", toolsHandler)
|
|
}
|
|
|
|
// Agent CRUD + shares API
|
|
if s.agentsHandler != nil {
|
|
s.agentsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Skill management API
|
|
if s.skillsHandler != nil {
|
|
s.skillsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// LLM trace listing API
|
|
if s.tracesHandler != nil {
|
|
s.tracesHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// External wake/trigger API
|
|
if s.wakeHandler != nil {
|
|
s.wakeHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// MCP server management API
|
|
if s.mcpHandler != nil {
|
|
s.mcpHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Custom tool CRUD API
|
|
if s.customToolsHandler != nil {
|
|
s.customToolsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Secure CLI credential CRUD API
|
|
if s.secureCLIHandler != nil {
|
|
s.secureCLIHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Channel instance CRUD API
|
|
if s.channelInstancesHandler != nil {
|
|
s.channelInstancesHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Provider & model CRUD API
|
|
if s.providersHandler != nil {
|
|
s.providersHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Delegation history API
|
|
if s.delegationsHandler != nil {
|
|
s.delegationsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Team event history API
|
|
if s.teamEventsHandler != nil {
|
|
s.teamEventsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Builtin tool management API
|
|
if s.builtinToolsHandler != nil {
|
|
s.builtinToolsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Pending messages API
|
|
if s.pendingMessagesHandler != nil {
|
|
s.pendingMessagesHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Memory management API
|
|
if s.memoryHandler != nil {
|
|
s.memoryHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Knowledge graph API
|
|
if s.kgHandler != nil {
|
|
s.kgHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Workspace file serving (available in all modes)
|
|
if s.filesHandler != nil {
|
|
s.filesHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Storage file management (browse/delete workspace files)
|
|
if s.storageHandler != nil {
|
|
s.storageHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Media upload endpoint (available in all modes)
|
|
if s.mediaUploadHandler != nil {
|
|
s.mediaUploadHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// Media serve endpoint (available in all modes)
|
|
if s.mediaServeHandler != nil {
|
|
s.mediaServeHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
if s.apiKeysHandler != nil {
|
|
s.apiKeysHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
if s.activityHandler != nil {
|
|
s.activityHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
if s.usageHandler != nil {
|
|
s.usageHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
if s.packagesHandler != nil {
|
|
s.packagesHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// API documentation (OpenAPI spec + Swagger UI)
|
|
if s.docsHandler != nil {
|
|
s.docsHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// OAuth endpoints (available in all modes)
|
|
if s.oauthHandler != nil {
|
|
s.oauthHandler.RegisterRoutes(mux)
|
|
}
|
|
|
|
// MCP bridge: expose GoClaw tools to Claude CLI via streamable-http.
|
|
// Only listens on localhost (CLI runs on the same machine).
|
|
// Protected by gateway token when configured.
|
|
// Agent context (X-Agent-ID, X-User-ID) is injected from request headers.
|
|
if s.tools != nil {
|
|
bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus)
|
|
var handler http.Handler = bridgeContextMiddleware(s.cfg.Gateway.Token, bridgeHandler)
|
|
if s.cfg.Gateway.Token != "" {
|
|
handler = tokenAuthMiddleware(s.cfg.Gateway.Token, handler)
|
|
} else {
|
|
slog.Warn("security.mcp_bridge: no gateway token configured, MCP bridge tools are unauthenticated")
|
|
}
|
|
mux.Handle("/mcp/bridge", handler)
|
|
}
|
|
|
|
s.mux = mux
|
|
return mux
|
|
}
|
|
|
|
// bridgeContextMiddleware extracts X-Agent-ID and X-User-ID headers from the
|
|
// MCP bridge request and injects them into the context so bridge tools can
|
|
// access agent/user scope. When a gateway token is configured, the context
|
|
// headers must be accompanied by a valid X-Bridge-Sig HMAC to prevent forgery.
|
|
func bridgeContextMiddleware(gatewayToken string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
agentIDStr := r.Header.Get("X-Agent-ID")
|
|
userID := r.Header.Get("X-User-ID")
|
|
channel := r.Header.Get("X-Channel")
|
|
chatID := r.Header.Get("X-Chat-ID")
|
|
peerKind := r.Header.Get("X-Peer-Kind")
|
|
|
|
if agentIDStr != "" || userID != "" {
|
|
// Reject context headers when no gateway token — prevents unauthenticated impersonation.
|
|
if gatewayToken == "" {
|
|
slog.Warn("security.mcp_bridge: no gateway token, ignoring context headers",
|
|
"agent_id", agentIDStr, "user_id", userID)
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Verify HMAC signature over all context fields.
|
|
sig := r.Header.Get("X-Bridge-Sig")
|
|
if !providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, sig) {
|
|
slog.Warn("security.mcp_bridge: invalid bridge context signature",
|
|
"agent_id", agentIDStr, "user_id", userID)
|
|
http.Error(w, `{"error":"invalid bridge context signature"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if agentIDStr != "" {
|
|
if id, err := uuid.Parse(agentIDStr); err == nil {
|
|
ctx = store.WithAgentID(ctx, id)
|
|
}
|
|
}
|
|
if userID != "" {
|
|
ctx = store.WithUserID(ctx, userID)
|
|
}
|
|
}
|
|
|
|
// Inject channel routing context for tools like message, cron, etc.
|
|
if channel != "" {
|
|
ctx = tools.WithToolChannel(ctx, channel)
|
|
}
|
|
if chatID != "" {
|
|
ctx = tools.WithToolChatID(ctx, chatID)
|
|
}
|
|
if peerKind != "" {
|
|
ctx = tools.WithToolPeerKind(ctx, peerKind)
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// tokenAuthMiddleware wraps an http.Handler with Bearer token authentication.
|
|
func tokenAuthMiddleware(token string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
provided := strings.TrimPrefix(auth, "Bearer ")
|
|
if !strings.HasPrefix(auth, "Bearer ") || subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Start begins listening for WebSocket and HTTP connections.
|
|
func (s *Server) Start(ctx context.Context) error {
|
|
mux := s.BuildMux()
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
|
|
s.httpServer = &http.Server{
|
|
Addr: addr,
|
|
Handler: mux,
|
|
}
|
|
|
|
slog.Info("gateway starting", "addr", addr)
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
s.httpServer.Shutdown(shutdownCtx)
|
|
}()
|
|
|
|
if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
|
return fmt.Errorf("gateway server: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleWebSocket upgrades HTTP to WebSocket and manages the connection.
|
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
slog.Error("websocket upgrade failed", "error", err)
|
|
return
|
|
}
|
|
|
|
client := NewClient(conn, s, clientIP(r))
|
|
s.registerClient(client)
|
|
|
|
defer func() {
|
|
s.unregisterClient(client)
|
|
client.Close()
|
|
}()
|
|
|
|
client.Run(r.Context())
|
|
}
|
|
|
|
// handleHealth returns a simple health check response.
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w, `{"status":"ok","protocol":%d}`, protocol.ProtocolVersion)
|
|
}
|
|
|
|
// clientIP extracts the real client IP from the request, checking proxy headers first.
|
|
func clientIP(r *http.Request) string {
|
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
if i := strings.IndexByte(fwd, ','); i > 0 {
|
|
return strings.TrimSpace(fwd[:i])
|
|
}
|
|
return strings.TrimSpace(fwd)
|
|
}
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
return host
|
|
}
|
|
|
|
// Router returns the method router for registering additional handlers.
|
|
func (s *Server) Router() *MethodRouter { return s.router }
|
|
|
|
// SetPolicyEngine sets the permission policy engine for RPC method authorization.
|
|
func (s *Server) SetPolicyEngine(pe *permissions.PolicyEngine) { s.policyEngine = pe }
|
|
|
|
// SetPairingService sets the pairing service for channel authentication.
|
|
func (s *Server) SetPairingService(ps store.PairingStore) { s.pairingService = ps }
|
|
|
|
// SetAgentsHandler sets the agent CRUD handler.
|
|
func (s *Server) SetAgentsHandler(h *httpapi.AgentsHandler) { s.agentsHandler = h }
|
|
|
|
// SetSkillsHandler sets the skill management handler.
|
|
func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.skillsHandler = h }
|
|
|
|
// SetTracesHandler sets the LLM trace listing handler.
|
|
func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = h }
|
|
|
|
// SetWakeHandler sets the external wake/trigger handler.
|
|
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h }
|
|
|
|
// SetMCPHandler sets the MCP server management handler.
|
|
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
|
|
|
|
// SetCustomToolsHandler sets the custom tool CRUD handler.
|
|
func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h }
|
|
|
|
// SetChannelInstancesHandler sets the channel instance CRUD handler.
|
|
func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) {
|
|
s.channelInstancesHandler = h
|
|
}
|
|
|
|
// SetProvidersHandler sets the provider CRUD handler.
|
|
func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h }
|
|
|
|
// SetDelegationsHandler sets the delegation history handler.
|
|
func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h }
|
|
|
|
// SetTeamEventsHandler sets the team event history handler.
|
|
func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) { s.teamEventsHandler = h }
|
|
|
|
// SetPendingMessagesHandler sets the pending messages handler.
|
|
func (s *Server) SetPendingMessagesHandler(h *httpapi.PendingMessagesHandler) {
|
|
s.pendingMessagesHandler = h
|
|
}
|
|
|
|
// SetBuiltinToolsHandler sets the builtin tool management handler.
|
|
func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) {
|
|
s.builtinToolsHandler = h
|
|
}
|
|
|
|
// SetSecureCLIHandler sets the secure CLI credential CRUD handler.
|
|
func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) { s.secureCLIHandler = h }
|
|
|
|
// SetPackagesHandler sets the runtime package management handler.
|
|
func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) { s.packagesHandler = h }
|
|
|
|
// SetOAuthHandler sets the OAuth handler (available in all modes).
|
|
func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h }
|
|
|
|
// SetAPIKeysHandler sets the API key management handler.
|
|
func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h }
|
|
|
|
// SetAPIKeyStore sets the API key store for token-based auth lookup.
|
|
func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st }
|
|
|
|
// SetFilesHandler sets the workspace file serving handler.
|
|
func (s *Server) SetFilesHandler(h *httpapi.FilesHandler) { s.filesHandler = h }
|
|
|
|
// SetStorageHandler sets the storage file management handler.
|
|
func (s *Server) SetStorageHandler(h *httpapi.StorageHandler) { s.storageHandler = h }
|
|
|
|
// SetMediaUploadHandler sets the media upload handler.
|
|
func (s *Server) SetMediaUploadHandler(h *httpapi.MediaUploadHandler) { s.mediaUploadHandler = h }
|
|
|
|
// SetMediaServeHandler sets the media serve handler.
|
|
func (s *Server) SetMediaServeHandler(h *httpapi.MediaServeHandler) { s.mediaServeHandler = h }
|
|
|
|
// SetMemoryHandler sets the memory management handler.
|
|
func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler = h }
|
|
|
|
// SetKnowledgeGraphHandler sets the knowledge graph handler.
|
|
func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s.kgHandler = h }
|
|
|
|
// SetActivityHandler sets the activity audit log handler.
|
|
func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h }
|
|
|
|
// SetUsageHandler sets the usage analytics handler.
|
|
func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.usageHandler = h }
|
|
|
|
// SetDocsHandler sets the OpenAPI spec + Swagger UI handler.
|
|
func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.docsHandler = h }
|
|
|
|
// SetAgentStore sets the agent store for context injection in tools_invoke.
|
|
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
|
|
|
|
// SetMessageBus sets the message bus for MCP bridge media delivery.
|
|
func (s *Server) SetMessageBus(mb *bus.MessageBus) { s.msgBus = mb }
|
|
|
|
// SetVersion sets the server version for health responses.
|
|
func (s *Server) SetVersion(v string) { s.version = v }
|
|
|
|
// SetDB sets the database connection for health check pings.
|
|
func (s *Server) SetDB(db interface{ PingContext(context.Context) error }) { s.db = db }
|
|
|
|
// StartedAt returns the server start time.
|
|
func (s *Server) StartedAt() time.Time { return s.startedAt }
|
|
|
|
// Version returns the server version string.
|
|
func (s *Server) Version() string { return s.version }
|
|
|
|
// ClientList returns a snapshot of all connected clients.
|
|
func (s *Server) ClientList() []*Client {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
list := make([]*Client, 0, len(s.clients))
|
|
for _, c := range s.clients {
|
|
list = append(list, c)
|
|
}
|
|
return list
|
|
}
|
|
|
|
// BroadcastEvent sends an event to all connected clients.
|
|
func (s *Server) BroadcastEvent(event protocol.EventFrame) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, client := range s.clients {
|
|
client.SendEvent(event)
|
|
}
|
|
}
|
|
|
|
// DisconnectByPairing force-closes WebSocket connections authenticated via the
|
|
// given pairing senderID and channel. Called after revoking a paired device so
|
|
// that the revoked client cannot continue operating with its old role.
|
|
func (s *Server) DisconnectByPairing(senderID, channel string) {
|
|
s.mu.RLock()
|
|
var targets []*Client
|
|
for _, c := range s.clients {
|
|
if c.pairedSenderID == senderID && c.pairedChannel == channel {
|
|
targets = append(targets, c)
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
for _, c := range targets {
|
|
slog.Info("disconnecting revoked paired device", "client", c.id, "sender_id", senderID, "channel", channel)
|
|
c.conn.Close()
|
|
}
|
|
}
|
|
|
|
func (s *Server) registerClient(c *Client) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.clients[c.id] = c
|
|
|
|
// Subscribe to bus events for this client (skip internal cache events)
|
|
s.eventPub.Subscribe(c.id, func(event bus.Event) {
|
|
if strings.HasPrefix(event.Name, "cache.") {
|
|
return // internal event, don't forward to WS clients
|
|
}
|
|
c.SendEvent(*protocol.NewEvent(event.Name, event.Payload))
|
|
})
|
|
|
|
slog.Info("client connected", "id", c.id)
|
|
}
|
|
|
|
func (s *Server) unregisterClient(c *Client) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.clients, c.id)
|
|
s.eventPub.Unsubscribe(c.id)
|
|
if s.logTee != nil {
|
|
s.logTee.Unsubscribe(c.id)
|
|
}
|
|
slog.Info("client disconnected", "id", c.id)
|
|
}
|
|
|
|
// SetLogTee attaches a LogTee so that disconnecting clients are auto-unsubscribed.
|
|
func (s *Server) SetLogTee(lt *LogTee) {
|
|
s.logTee = lt
|
|
}
|
|
|
|
// StartTestServer creates a listener on :0 (random port) and returns the
|
|
// actual address and a start function. Used for integration tests.
|
|
func StartTestServer(s *Server, ctx context.Context) (addr string, start func()) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ws", s.handleWebSocket)
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
|
|
isManaged := s.agentStore != nil
|
|
chatHandler := httpapi.NewChatCompletionsHandler(s.agents, s.sessions, s.cfg.Gateway.Token, isManaged)
|
|
if s.rateLimiter.Enabled() {
|
|
chatHandler.SetRateLimiter(s.rateLimiter.Allow)
|
|
}
|
|
mux.Handle("/v1/chat/completions", chatHandler)
|
|
|
|
responsesHandler := httpapi.NewResponsesHandler(s.agents, s.sessions, s.cfg.Gateway.Token)
|
|
mux.Handle("/v1/responses", responsesHandler)
|
|
|
|
if s.tools != nil {
|
|
toolsHandler := httpapi.NewToolsInvokeHandler(s.tools, s.cfg.Gateway.Token, s.agentStore)
|
|
mux.Handle("/v1/tools/invoke", toolsHandler)
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
panic("listen: " + err.Error())
|
|
}
|
|
|
|
s.httpServer = &http.Server{Handler: mux}
|
|
addr = ln.Addr().String()
|
|
|
|
start = func() {
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
s.httpServer.Shutdown(shutdownCtx)
|
|
}()
|
|
s.httpServer.Serve(ln)
|
|
}
|
|
|
|
return addr, start
|
|
}
|