mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-12 09:05:24 +00:00
36c88fdd6e
bridgeContextMiddleware injects the agent UUID (store.WithAgentID) but never the agent key, so session tools (sessions_list/history/send/status) that resolve the caller via tools.ToolAgentKeyFromCtx always get "" and fail with "agent context required" when invoked over /mcp/bridge (e.g. by a claude-cli provider agent). Session keys are namespaced by agent key (agent:<key>:...), not UUID, and the bridge has no RunContext fallback. Inject the key in the same block that already fetches the agent for shell-deny overrides — symmetric with store.WithShellDenyGroups, no extra DB call, no new imports. #1094 makes the same fix at this site but bundles it into a large, stale (CONFLICTING) ACP/i18n refactor; this is the minimal extraction of that slice. It omits #1094's companion store.WithAgentKey write, which nothing on dev reads (every session tool reads tools.ToolAgentKeyFromCtx). Add internal/gateway/bridge_context_test.go: a regression guard asserting a signed X-Agent-ID lands the agent key in tools.ToolAgentKeyFromCtx, plus a no-store negative control. Signed-off-by: Jaegeon Oh <zezaeoh@gmail.com>
835 lines
30 KiB
Go
835 lines
30 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"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/internal/webui"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// Server is the main gateway server handling WebSocket and HTTP connections.
|
|
// routeRegistrar is implemented by all HTTP API handlers that register routes on a mux.
|
|
type routeRegistrar interface {
|
|
RegisterRoutes(mux *http.ServeMux)
|
|
}
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
eventPub bus.EventPublisher
|
|
agents *agent.Router
|
|
sessions store.SessionStore
|
|
tools *tools.Registry
|
|
router *MethodRouter
|
|
|
|
// HTTP API handlers — all implement routeRegistrar.
|
|
// Registered via Set*Handler() setters, routes added in BuildMux() via single loop.
|
|
handlers []routeRegistrar
|
|
|
|
// Non-handler dependencies (don't implement RegisterRoutes)
|
|
policyEngine *permissions.PolicyEngine
|
|
pairingService store.PairingStore
|
|
apiKeyStore store.APIKeyStore // for API key auth lookup
|
|
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
|
|
updateChecker *UpdateChecker
|
|
|
|
logTee *LogTee // optional; auto-unsubscribes clients on disconnect
|
|
postTurn tools.PostTurnProcessor // optional; for team task dispatch in HTTP API paths
|
|
|
|
httpServer *http.Server
|
|
mux *http.ServeMux
|
|
|
|
// publicURLSnapshot remembers the gateway's externally reachable base URL
|
|
// learned from inbound HTTP requests. Reset to a fresh snapshot per Server
|
|
// so test servers don't share state. Read by RPC methods that need to
|
|
// advertise URLs back to external systems (e.g. Bitrix24 install link).
|
|
publicURLSnapshot *PublicURLSnapshot
|
|
}
|
|
|
|
// SetPostTurnProcessor sets the post-turn processor for team task dispatch in HTTP API handlers.
|
|
func (s *Server) SetPostTurnProcessor(pt tools.PostTurnProcessor) {
|
|
s.postTurn = pt
|
|
}
|
|
|
|
// 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(),
|
|
publicURLSnapshot: NewPublicURLSnapshot(),
|
|
}
|
|
|
|
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 }
|
|
|
|
// PublicURLSnapshot returns the snapshot of the gateway's externally reachable
|
|
// base URL. Updated by the snapshot middleware on every inbound request.
|
|
func (s *Server) PublicURLSnapshot() *PublicURLSnapshot { return s.publicURLSnapshot }
|
|
|
|
// 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, isManaged)
|
|
if s.rateLimiter.Enabled() {
|
|
chatHandler.SetRateLimiter(s.rateLimiter.Allow)
|
|
}
|
|
if s.postTurn != nil {
|
|
chatHandler.SetPostTurnProcessor(s.postTurn)
|
|
}
|
|
mux.Handle("/v1/chat/completions", chatHandler)
|
|
|
|
// OpenResponses protocol
|
|
responsesHandler := httpapi.NewResponsesHandler(s.agents, s.sessions)
|
|
if s.postTurn != nil {
|
|
responsesHandler.SetPostTurnProcessor(s.postTurn)
|
|
}
|
|
mux.Handle("/v1/responses", responsesHandler)
|
|
|
|
// Direct tool invocation
|
|
if s.tools != nil {
|
|
toolsHandler := httpapi.NewToolsInvokeHandler(s.tools, s.agentStore)
|
|
mux.Handle("/v1/tools/invoke", toolsHandler)
|
|
}
|
|
|
|
// Read-only HTTP compatibility for automation clients.
|
|
if s.sessions != nil {
|
|
httpapi.NewSessionsHandler(s.sessions, s.cfg.Gateway.OwnerIDs).RegisterRoutes(mux)
|
|
}
|
|
|
|
// Register all HTTP API handlers (agents, skills, teams, storage, etc.)
|
|
for _, h := range s.handlers {
|
|
if h != nil {
|
|
h.RegisterRoutes(mux)
|
|
}
|
|
}
|
|
|
|
httpapi.RegisterAPINotFoundRoute(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; disabled when no token is configured to
|
|
// prevent unauthenticated tool invocations if port is exposed.
|
|
if s.tools != nil {
|
|
if s.cfg.Gateway.Token != "" {
|
|
bridgeHandler := mcpbridge.NewBridgeServer(s.tools, "1.0.0", s.msgBus)
|
|
handler := tokenAuthMiddleware(s.cfg.Gateway.Token,
|
|
bridgeContextMiddleware(s.cfg.Gateway.Token, s.agentStore, bridgeHandler))
|
|
mux.Handle("/mcp/bridge", handler)
|
|
} else {
|
|
slog.Warn("security.mcp_bridge_disabled: no gateway token configured, MCP bridge is disabled")
|
|
mux.HandleFunc("/mcp/bridge", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"mcp bridge disabled: set GOCLAW_GATEWAY_TOKEN to enable"}`))
|
|
})
|
|
}
|
|
}
|
|
|
|
// Embedded web UI (built with -tags embedui). Catch-all after all API routes.
|
|
// When the build does NOT include the embedui tag, webui.Handler() returns nil
|
|
// and there's no handler for "/" — http.ServeMux would then return an opaque
|
|
// 404 for the root URL, confusing operators who open the deployed URL in a
|
|
// browser to check the service. Install a minimal JSON index handler in that
|
|
// case so the root responds with something useful (and any unmatched path
|
|
// still returns 404, just with a JSON body).
|
|
if h := webui.Handler(); h != nil {
|
|
mux.Handle("/", h)
|
|
slog.Info("serving embedded web UI")
|
|
} else {
|
|
mux.HandleFunc("/", s.handleIndex)
|
|
}
|
|
|
|
s.mux = mux
|
|
return mux
|
|
}
|
|
|
|
// bridgeContextMiddleware extracts X-Agent-ID, X-User-ID, and X-Workspace headers
|
|
// from the MCP bridge request and injects them into the context so bridge tools can
|
|
// access agent/user scope and resolve workspace-relative paths.
|
|
// 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, agentStore store.AgentStore, 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")
|
|
workspace := r.Header.Get("X-Workspace")
|
|
localKey := r.Header.Get("X-Local-Key")
|
|
sessionKey := r.Header.Get("X-Session-Key")
|
|
|
|
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.
|
|
tenantIDStr := r.Header.Get("X-Tenant-ID")
|
|
sig := r.Header.Get("X-Bridge-Sig")
|
|
ok, tenantVerified := providers.VerifyBridgeContext(gatewayToken, agentIDStr, userID, channel, chatID, peerKind, workspace, tenantIDStr, sig, localKey, sessionKey)
|
|
if !ok {
|
|
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)
|
|
|
|
// Inject per-agent shell deny group overrides so the exec tool
|
|
// respects the same policy as the normal agent loop.
|
|
if agentStore != nil {
|
|
ag, err := agentStore.GetByIDUnscoped(ctx, id)
|
|
if err == nil && ag != nil {
|
|
// Propagate the agent key so bridged session tools (sessions_list/
|
|
// history/send) can resolve identity via ToolAgentKeyFromCtx. The MCP
|
|
// bridge otherwise injects only the agent UUID, leaving the key empty
|
|
// -> session tools fail with "agent context required".
|
|
ctx = tools.WithToolAgentKey(ctx, ag.AgentKey)
|
|
groups := ag.ParseShellDenyGroups()
|
|
if groups != nil {
|
|
ctx = store.WithShellDenyGroups(ctx, groups)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if userID != "" {
|
|
ctx = store.WithUserID(ctx, userID)
|
|
}
|
|
// Only inject tenant_id when HMAC actually covers it (level 1).
|
|
// Fallback levels (pre-tenantID sessions) must not trust unsigned tenant headers.
|
|
if tenantVerified && tenantIDStr != "" {
|
|
if tid, err := uuid.Parse(tenantIDStr); err == nil {
|
|
ctx = store.WithTenantID(ctx, tid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
// Inject workspace so bridge tools (read_image, read_file, etc.) can resolve paths.
|
|
// Only when agent context is present (HMAC-protected) to prevent unauthenticated path injection.
|
|
if workspace != "" && (agentIDStr != "" || userID != "") {
|
|
ctx = tools.WithToolWorkspace(ctx, workspace)
|
|
}
|
|
// Routing context (localKey, sessionKey) is injected unconditionally like channel/chatID.
|
|
// These are used for message routing (forum topics), not security-sensitive operations.
|
|
// Without valid agent context, tool execution will fail anyway.
|
|
if localKey != "" {
|
|
ctx = tools.WithToolLocalKey(ctx, localKey)
|
|
}
|
|
if sessionKey != "" {
|
|
ctx = tools.WithToolSessionKey(ctx, sessionKey)
|
|
}
|
|
|
|
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()
|
|
|
|
// Wrap with CORS for desktop dev mode (Wails serves frontend on different port).
|
|
var handler http.Handler = mux
|
|
if os.Getenv("GOCLAW_DESKTOP") == "1" {
|
|
handler = desktopCORS(mux)
|
|
}
|
|
// NOTE: The public-URL snapshot is intentionally NOT updated by a global
|
|
// middleware. An unauthenticated probe with a forged Host header could
|
|
// otherwise poison the URL we hand back to clients (which then ends up
|
|
// in OAuth callbacks and would leak tokens to an attacker-controlled
|
|
// host). Instead, the snapshot is updated inside handleConnect AFTER
|
|
// token authentication succeeds (see internal/gateway/router.go), and
|
|
// from /bitrix24/install (already gated by valid OAuth state).
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
|
|
s.httpServer = &http.Server{
|
|
Addr: addr,
|
|
Handler: handler,
|
|
}
|
|
|
|
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))
|
|
// Capture the public URL from the HTTP upgrade request. We DON'T snapshot
|
|
// it server-wide yet — that happens only after the client authenticates
|
|
// in handleConnect. This prevents an unauthenticated probe with a forged
|
|
// Host header from poisoning the gateway-wide public URL.
|
|
client.setUpgradeURL(derivePublicURLFromRequest(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)
|
|
}
|
|
|
|
// handleIndex is the fallback "/" handler when no embedded web UI is present.
|
|
// It returns a small JSON service-info document for exact-match "/" requests
|
|
// and a JSON 404 for everything else — http.ServeMux routes "/" as a
|
|
// catch-all, so unrelated paths fall through here too.
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if r.URL.Path != "/" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprintf(w,
|
|
`{"service":"goclaw","status":"ok","protocol":%d,`+
|
|
`"endpoints":["/health","/v1/chat/completions","/v1/responses","/v1/tools/invoke","/ws"]}`,
|
|
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.handlers = append(s.handlers, h) }
|
|
|
|
// SetSkillsHandler sets the skill management handler.
|
|
func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetTracesHandler sets the LLM trace listing handler.
|
|
func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetWakeHandler sets the external wake/trigger handler.
|
|
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetMCPHandler sets the MCP server management handler.
|
|
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.handlers = append(s.handlers, h) }
|
|
func (s *Server) SetMCPUserCredentialsHandler(h *httpapi.MCPUserCredentialsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetChannelInstancesHandler sets the channel instance CRUD handler.
|
|
func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetProvidersHandler sets the provider CRUD handler.
|
|
func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetTeamEventsHandler sets the team event history handler.
|
|
func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetTeamAttachmentsHandler sets the team attachment download handler.
|
|
func (s *Server) SetTeamAttachmentsHandler(h *httpapi.TeamAttachmentsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetWorkspaceUploadHandler sets the team workspace file upload handler.
|
|
func (s *Server) SetWorkspaceUploadHandler(h *httpapi.WorkspaceUploadHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetPendingMessagesHandler sets the pending messages handler.
|
|
func (s *Server) SetPendingMessagesHandler(h *httpapi.PendingMessagesHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetBuiltinToolsHandler sets the builtin tool management handler.
|
|
func (s *Server) SetBuiltinToolsHandler(h *httpapi.BuiltinToolsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetSecureCLIHandler sets the secure CLI credential CRUD handler.
|
|
func (s *Server) SetSecureCLIHandler(h *httpapi.SecureCLIHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetSecureCLIGrantHandler sets the per-agent secure CLI grant handler.
|
|
func (s *Server) SetSecureCLIGrantHandler(h *httpapi.SecureCLIGrantHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetBrowserCookiesHandler sets the selected browser-cookie sync handler.
|
|
func (s *Server) SetBrowserCookiesHandler(h *httpapi.BrowserCookiesHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetPackagesHandler sets the runtime package management handler.
|
|
func (s *Server) SetPackagesHandler(h *httpapi.PackagesHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetGatewayUpgradeHandler sets the host-local gateway upgrade trigger handler.
|
|
func (s *Server) SetGatewayUpgradeHandler(h *httpapi.GatewayUpgradeHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetOAuthHandler sets the OAuth handler (available in all modes).
|
|
func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetAPIKeysHandler sets the API key management handler.
|
|
func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetWebhooksAdminHandler registers the webhook admin CRUD handler.
|
|
func (s *Server) SetWebhooksAdminHandler(h *httpapi.WebhooksAdminHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetWebhookMessageHandler registers the POST /v1/webhooks/message runtime handler.
|
|
// Only called when edition.Current().AllowsChannels() is true (Standard edition).
|
|
func (s *Server) SetWebhookMessageHandler(h *httpapi.WebhookMessageHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetWebhookLLMHandler registers the POST /v1/webhooks/llm runtime handler.
|
|
// Available in all editions (Standard + Lite). Localhost-only enforcement is
|
|
// handled by WebhookAuthMiddleware at request time via webhook.LocalhostOnly.
|
|
func (s *Server) SetWebhookLLMHandler(h *httpapi.WebhookLLMHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetTenantsHandler sets the tenant management handler.
|
|
func (s *Server) SetTenantsHandler(h *httpapi.TenantsHandler) {
|
|
s.handlers = append(s.handlers, 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.handlers = append(s.handlers, h) }
|
|
|
|
// SetStorageHandler sets the storage file management handler.
|
|
func (s *Server) SetStorageHandler(h *httpapi.StorageHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetMediaUploadHandler sets the media upload handler.
|
|
func (s *Server) SetMediaUploadHandler(h *httpapi.MediaUploadHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetMediaServeHandler sets the media serve handler.
|
|
func (s *Server) SetMediaServeHandler(h *httpapi.MediaServeHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetMemoryHandler sets the memory management handler.
|
|
func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetKnowledgeGraphHandler sets the knowledge graph handler.
|
|
func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetEvolutionHandler sets the evolution metrics + suggestions handler.
|
|
func (s *Server) SetEvolutionHandler(h *httpapi.EvolutionHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetVoicesHandler sets the ElevenLabs voices list + refresh handler.
|
|
func (s *Server) SetVoicesHandler(h *httpapi.VoicesHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetTTSHandler sets the TTS synthesize handler.
|
|
func (s *Server) SetTTSHandler(h *httpapi.TTSHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetTTSConfigHandler sets the per-tenant TTS config handler.
|
|
func (s *Server) SetTTSConfigHandler(h *httpapi.TTSConfigHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetVaultHandler sets the Knowledge Vault document handler.
|
|
func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetVaultGraphHandler sets the lightweight graph visualization handler.
|
|
func (s *Server) SetVaultGraphHandler(h *httpapi.VaultGraphHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetEpisodicHandler sets the episodic memory handler.
|
|
func (s *Server) SetEpisodicHandler(h *httpapi.EpisodicHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetOrchestrationHandler sets the orchestration mode handler.
|
|
func (s *Server) SetOrchestrationHandler(h *httpapi.OrchestrationHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetV3FlagsHandler sets the per-agent v3 feature flag handler.
|
|
func (s *Server) SetV3FlagsHandler(h *httpapi.V3FlagsHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetActivityHandler sets the activity audit log handler.
|
|
func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetRuntimeLogsHandler sets the runtime log aggregate handler.
|
|
func (s *Server) SetRuntimeLogsHandler(h *httpapi.RuntimeLogsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetSystemConfigsHandler sets the system configs handler.
|
|
func (s *Server) SetSystemConfigsHandler(h *httpapi.SystemConfigsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetUsageHandler sets the usage analytics handler.
|
|
func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetUsageCapsHandler sets usage cap and model pricing handlers.
|
|
func (s *Server) SetUsageCapsHandler(h *httpapi.UsageCapsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetBackupHandler sets the system backup handler.
|
|
func (s *Server) SetBackupHandler(h *httpapi.BackupHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetRestoreHandler sets the system restore handler.
|
|
func (s *Server) SetRestoreHandler(h *httpapi.RestoreHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetBackupS3Handler sets the S3 backup integration handler.
|
|
func (s *Server) SetBackupS3Handler(h *httpapi.BackupS3Handler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetTenantBackupHandler sets the tenant-scoped backup/restore handler.
|
|
func (s *Server) SetTenantBackupHandler(h *httpapi.TenantBackupHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// SetDocsHandler sets the OpenAPI spec + Swagger UI handler.
|
|
func (s *Server) SetDocsHandler(h *httpapi.DocsHandler) { s.handlers = append(s.handlers, h) }
|
|
|
|
// SetEditionHandler sets the edition info handler.
|
|
func (s *Server) SetEditionHandler(h *httpapi.EditionHandler) { s.handlers = append(s.handlers, 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 }
|
|
|
|
// SetWorkstationsHandler sets the workstations CRUD handler (Standard edition only).
|
|
func (s *Server) SetWorkstationsHandler(h *httpapi.WorkstationsHandler) {
|
|
s.handlers = append(s.handlers, h)
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// StartUpdateChecker starts a background goroutine that periodically checks
|
|
// GitHub for new releases and caches the result for the health endpoint.
|
|
func (s *Server) StartUpdateChecker(ctx context.Context) {
|
|
s.updateChecker = NewUpdateChecker(s.version)
|
|
s.updateChecker.Start(ctx)
|
|
}
|
|
|
|
// 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 with per-user/team filtering.
|
|
s.eventPub.Subscribe(c.id, func(event bus.Event) {
|
|
if clientCanReceiveEvent(c, event) {
|
|
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, isManaged)
|
|
if s.rateLimiter.Enabled() {
|
|
chatHandler.SetRateLimiter(s.rateLimiter.Allow)
|
|
}
|
|
if s.postTurn != nil {
|
|
chatHandler.SetPostTurnProcessor(s.postTurn)
|
|
}
|
|
mux.Handle("/v1/chat/completions", chatHandler)
|
|
|
|
responsesHandler := httpapi.NewResponsesHandler(s.agents, s.sessions)
|
|
if s.postTurn != nil {
|
|
responsesHandler.SetPostTurnProcessor(s.postTurn)
|
|
}
|
|
mux.Handle("/v1/responses", responsesHandler)
|
|
|
|
if s.tools != nil {
|
|
toolsHandler := httpapi.NewToolsInvokeHandler(s.tools, 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
|
|
}
|
|
|
|
// desktopCORS wraps a handler with permissive CORS headers for desktop dev mode.
|
|
// Only active when GOCLAW_DESKTOP=1 (set by desktop app.go).
|
|
func desktopCORS(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GoClaw-Tenant-Id, X-GoClaw-User-Id")
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|