1package server
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net"
8 "net/http"
9 "net/url"
10 "os/user"
11 "runtime"
12 "strings"
13
14 "github.com/charmbracelet/crush/internal/backend"
15 "github.com/charmbracelet/crush/internal/config"
16 _ "github.com/charmbracelet/crush/internal/swagger"
17 httpswagger "github.com/swaggo/http-swagger/v2"
18)
19
20// ErrServerClosed is returned when the server is closed.
21var ErrServerClosed = http.ErrServerClosed
22
23// ParseHostURL parses a host URL into a [url.URL].
24func ParseHostURL(host string) (*url.URL, error) {
25 proto, addr, ok := strings.Cut(host, "://")
26 if !ok {
27 return nil, fmt.Errorf("invalid host format: %s", host)
28 }
29
30 var basePath string
31 if proto == "tcp" {
32 parsed, err := url.Parse("tcp://" + addr)
33 if err != nil {
34 return nil, fmt.Errorf("invalid tcp address: %v", err)
35 }
36 addr = parsed.Host
37 basePath = parsed.Path
38 }
39 return &url.URL{
40 Scheme: proto,
41 Host: addr,
42 Path: basePath,
43 }, nil
44}
45
46// DefaultHost returns the default server host.
47func DefaultHost() string {
48 sock := "crush.sock"
49 usr, err := user.Current()
50 if err == nil && usr.Uid != "" {
51 sock = fmt.Sprintf("crush-%s.sock", usr.Uid)
52 }
53 if runtime.GOOS == "windows" {
54 return fmt.Sprintf("npipe:////./pipe/%s", sock)
55 }
56 return fmt.Sprintf("unix:///tmp/%s", sock)
57}
58
59// Server represents a Crush server bound to a specific address.
60type Server struct {
61 // Addr can be a TCP address, a Unix socket path, or a Windows named pipe.
62 Addr string
63 network string
64
65 h *http.Server
66 ln net.Listener
67
68 backend *backend.Backend
69 logger *slog.Logger
70}
71
72// SetLogger sets the logger for the server.
73func (s *Server) SetLogger(logger *slog.Logger) {
74 s.logger = logger
75}
76
77// DefaultServer returns a new [Server] with the default address.
78func DefaultServer(cfg *config.ConfigStore) *Server {
79 hostURL, err := ParseHostURL(DefaultHost())
80 if err != nil {
81 panic("invalid default host")
82 }
83 return NewServer(cfg, hostURL.Scheme, hostURL.Host)
84}
85
86// NewServer creates a new [Server] with the given network and address.
87func NewServer(cfg *config.ConfigStore, network, address string) *Server {
88 s := new(Server)
89 s.Addr = address
90 s.network = network
91
92 // The backend is created with a shutdown callback that triggers
93 // a graceful server shutdown (e.g. when the last workspace is
94 // removed).
95 s.backend = backend.New(context.Background(), cfg, func() {
96 go func() {
97 slog.Info("Shutting down server...")
98 if err := s.Shutdown(context.Background()); err != nil {
99 slog.Error("Failed to shutdown server", "error", err)
100 }
101 }()
102 })
103 s.installHandler()
104 if network == "tcp" {
105 s.h.Addr = address
106 }
107 return s
108}
109
110// installHandler builds the protocol/router around s.backend and
111// assigns the resulting http.Server to s.h. Extracted from
112// [NewServer] so test harnesses can wire a Server around a
113// pre-constructed backend.
114func (s *Server) installHandler() {
115 var p http.Protocols
116 p.SetHTTP1(true)
117 p.SetUnencryptedHTTP2(true)
118 c := &controllerV1{backend: s.backend, server: s}
119 mux := http.NewServeMux()
120 mux.HandleFunc("GET /v1/health", c.handleGetHealth)
121 mux.HandleFunc("GET /v1/version", c.handleGetVersion)
122 mux.HandleFunc("GET /v1/config", c.handleGetConfig)
123 mux.HandleFunc("POST /v1/control", c.handlePostControl)
124 mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
125 mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
126 mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
127 mux.HandleFunc("POST /v1/workspaces/{id}/current-session", c.handlePostWorkspaceCurrentSession)
128 mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
129 mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
130 mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
131 mux.HandleFunc("GET /v1/workspaces/{id}/providers", c.handleGetWorkspaceProviders)
132 mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
133 mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
134 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
135 mux.HandleFunc("PUT /v1/workspaces/{id}/sessions/{sid}", c.handlePutWorkspaceSession)
136 mux.HandleFunc("DELETE /v1/workspaces/{id}/sessions/{sid}", c.handleDeleteWorkspaceSession)
137 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
138 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
139 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages/user", c.handleGetWorkspaceSessionUserMessages)
140 mux.HandleFunc("GET /v1/workspaces/{id}/messages/user", c.handleGetWorkspaceAllUserMessages)
141 mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/filetracker/files", c.handleGetWorkspaceSessionFileTrackerFiles)
142 mux.HandleFunc("POST /v1/workspaces/{id}/filetracker/read", c.handlePostWorkspaceFileTrackerRead)
143 mux.HandleFunc("GET /v1/workspaces/{id}/filetracker/lastread", c.handleGetWorkspaceFileTrackerLastRead)
144 mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
145 mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
146 mux.HandleFunc("POST /v1/workspaces/{id}/lsps/start", c.handlePostWorkspaceLSPStart)
147 mux.HandleFunc("POST /v1/workspaces/{id}/lsps/stop", c.handlePostWorkspaceLSPStopAll)
148 mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
149 mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
150 mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
151 mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
152 mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
153 mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
154 mux.HandleFunc("POST /v1/workspaces/{id}/agent/update", c.handlePostWorkspaceAgentUpdate)
155 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
156 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
157 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
158 mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
159 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
160 mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
161 mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
162 mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
163 mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
164 mux.HandleFunc("POST /v1/workspaces/{id}/config/model", c.handlePostWorkspaceConfigModel)
165 mux.HandleFunc("POST /v1/workspaces/{id}/config/compact", c.handlePostWorkspaceConfigCompact)
166 mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
167 mux.HandleFunc("POST /v1/workspaces/{id}/config/import-copilot", c.handlePostWorkspaceConfigImportCopilot)
168 mux.HandleFunc("POST /v1/workspaces/{id}/config/refresh-oauth", c.handlePostWorkspaceConfigRefreshOAuth)
169 mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
170 mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
171 mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
172 mux.HandleFunc("GET /v1/workspaces/{id}/skills", c.handleGetWorkspaceSkills)
173 mux.HandleFunc("POST /v1/workspaces/{id}/skills/read", c.handlePostWorkspaceSkillRead)
174 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
175 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
176 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
177 mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
178 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
179 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
180 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/enable", c.handlePostWorkspaceMCPEnableDocker)
181 mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/disable", c.handlePostWorkspaceMCPDisableDocker)
182 mux.Handle("/v1/docs/", httpswagger.WrapHandler)
183 s.h = &http.Server{
184 Protocols: &p,
185 Handler: s.recoverHandler(s.loggingHandler(mux)),
186 }
187}
188
189// Handler returns the server's HTTP handler. Exposed so test harnesses
190// can wrap it in an httptest.Server without going through the
191// production listener setup.
192func (s *Server) Handler() http.Handler {
193 return s.h.Handler
194}
195
196// Serve accepts incoming connections on the listener.
197func (s *Server) Serve(ln net.Listener) error {
198 return s.h.Serve(ln)
199}
200
201// ListenAndServe starts the server and begins accepting connections.
202func (s *Server) ListenAndServe() error {
203 if s.ln != nil {
204 return fmt.Errorf("server already started")
205 }
206 ln, err := listen(s.network, s.Addr)
207 if err != nil {
208 return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
209 }
210 return s.Serve(ln)
211}
212
213func (s *Server) closeListener() {
214 if s.ln != nil {
215 s.ln.Close()
216 s.ln = nil
217 }
218}
219
220// Close force closes all listeners and connections.
221func (s *Server) Close() error {
222 defer func() { s.closeListener() }()
223 return s.h.Close()
224}
225
226// Shutdown gracefully shuts down the server without interrupting active
227// connections.
228func (s *Server) Shutdown(ctx context.Context) error {
229 defer func() { s.closeListener() }()
230 return s.h.Shutdown(ctx)
231}
232
233func (s *Server) logDebug(r *http.Request, msg string, args ...any) {
234 if s.logger != nil {
235 s.logger.With(
236 slog.String("method", r.Method),
237 slog.String("url", r.URL.String()),
238 slog.String("remote_addr", r.RemoteAddr),
239 ).Debug(msg, args...)
240 }
241}
242
243func (s *Server) logError(r *http.Request, msg string, args ...any) {
244 if s.logger != nil {
245 s.logger.With(
246 slog.String("method", r.Method),
247 slog.String("url", r.URL.String()),
248 slog.String("remote_addr", r.RemoteAddr),
249 ).Error(msg, args...)
250 }
251}