191 lines
6.8 KiB
Go
191 lines
6.8 KiB
Go
// Package server exposes the Control Center HTTP API. Loopback-first, token
|
|
// authenticated: console token for the human UI, per-agent bearer tokens for
|
|
// /api/v1/agent/*.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/controlcenter/audit"
|
|
"chat-agent-system/internal/controlcenter/config"
|
|
"chat-agent-system/internal/controlcenter/docs"
|
|
"chat-agent-system/internal/controlcenter/embed"
|
|
"chat-agent-system/internal/controlcenter/store"
|
|
"chat-agent-system/internal/controlcenter/webui"
|
|
)
|
|
|
|
type ctxKey string
|
|
|
|
const ctxAgent ctxKey = "cc-agent"
|
|
|
|
// AgentFromCtx returns the authenticated agent name, or "" for console users.
|
|
func AgentFromCtx(ctx context.Context) string {
|
|
v, _ := ctx.Value(ctxAgent).(string)
|
|
return v
|
|
}
|
|
|
|
type Server struct {
|
|
cfg config.Config
|
|
store *store.Store
|
|
agents *AgentAuthenticator
|
|
mux *http.ServeMux
|
|
broker *Broker
|
|
emb embed.Embedder // nil = search degrades to keyword-only
|
|
docSync *docs.Syncer // nil = artifact indexing disabled
|
|
audit *audit.Runner // nil = audit endpoints unavailable
|
|
}
|
|
|
|
func New(cfg config.Config, st *store.Store) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
store: st,
|
|
agents: &AgentAuthenticator{st: st},
|
|
mux: http.NewServeMux(),
|
|
broker: NewBroker(),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler { return s.authMiddleware(s.mux) }
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
// Agent API
|
|
s.mux.HandleFunc("GET /api/v1/agent/tasks/next", s.handleAgentNext)
|
|
s.mux.HandleFunc("GET /api/v1/agent/tasks/{id}/instruction", s.handleAgentInstruction)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/claim", s.handleAgentClaim)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/heartbeat", s.handleAgentHeartbeat)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/events", s.handleAgentEvent)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/artifacts", s.handleAgentArtifact)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/advance-request", s.handleAgentAdvanceRequest)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/decisions", s.handleAgentDecision)
|
|
s.mux.HandleFunc("POST /api/v1/agent/tasks/{id}/release", s.handleAgentRelease)
|
|
s.mux.HandleFunc("POST /api/v1/agent/reports", s.handleAgentReport)
|
|
|
|
// Console API
|
|
s.mux.HandleFunc("POST /api/v1/plans", s.handlePlanCreate)
|
|
s.mux.HandleFunc("GET /api/v1/plans", s.handlePlanList)
|
|
s.mux.HandleFunc("PATCH /api/v1/plans/{id}", s.handlePlanPatch)
|
|
s.mux.HandleFunc("POST /api/v1/tasks", s.handleTaskCreate)
|
|
s.mux.HandleFunc("GET /api/v1/tasks", s.handleTaskList)
|
|
s.mux.HandleFunc("GET /api/v1/tasks/{id}", s.handleTaskGet)
|
|
s.mux.HandleFunc("PATCH /api/v1/tasks/{id}", s.handleTaskPatch)
|
|
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/advance", s.handleTaskAdvance)
|
|
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/send-back", s.handleTaskSendBack)
|
|
s.mux.HandleFunc("POST /api/v1/tasks/{id}/stages/{stage}/external-submit", s.handleTaskExternalSubmit)
|
|
s.mux.HandleFunc("GET /api/v1/tasks/{id}/pipeline", s.handleTaskPipeline)
|
|
s.mux.HandleFunc("POST /api/v1/tasks/{id}/export-doc", s.handleTaskExportDoc)
|
|
s.mux.HandleFunc("GET /api/v1/decisions", s.handleDecisionList)
|
|
s.mux.HandleFunc("POST /api/v1/decisions/{id}/resolve", s.handleDecisionResolve)
|
|
s.mux.HandleFunc("GET /api/v1/stage-settings", s.handleStageSettingsGet)
|
|
s.mux.HandleFunc("PUT /api/v1/stage-settings", s.handleStageSettingsPut)
|
|
s.mux.HandleFunc("GET /api/v1/overview", s.handleOverview)
|
|
s.mux.HandleFunc("GET /api/v1/stream", s.handleStream)
|
|
s.mux.HandleFunc("GET /api/v1/docs", s.handleDocList)
|
|
s.mux.HandleFunc("GET /api/v1/docs/search", s.handleDocSearch)
|
|
s.mux.HandleFunc("GET /api/v1/docs/{id}", s.handleDocGet)
|
|
s.mux.HandleFunc("GET /api/v1/audits/runs", s.handleAuditRunList)
|
|
s.mux.HandleFunc("GET /api/v1/audits/findings", s.handleAuditFindingList)
|
|
s.mux.HandleFunc("POST /api/v1/audits/run", s.handleAuditRun)
|
|
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/convert", s.handleAuditFindingConvert)
|
|
s.mux.HandleFunc("POST /api/v1/audits/findings/{id}/dismiss", s.handleAuditFindingDismiss)
|
|
|
|
// SPA (embedded build of control-center/web); auth middleware already
|
|
// passes non-/api paths through.
|
|
s.mux.Handle("GET /", webui.Handler())
|
|
}
|
|
|
|
// SetDocs attaches the embedding client and document syncer used by the
|
|
// /api/v1/docs search routes and asynchronous artifact indexing.
|
|
func (s *Server) SetDocs(emb embed.Embedder, syncer *docs.Syncer) {
|
|
s.emb = emb
|
|
s.docSync = syncer
|
|
}
|
|
|
|
// indexArtifactAsync indexes an artifact's markdown in the background;
|
|
// failures are logged only.
|
|
func (s *Server) indexArtifactAsync(artifactID string) {
|
|
if s.docSync == nil {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
if err := s.docSync.IndexArtifact(ctx, artifactID); err != nil {
|
|
slog.Error("artifact indexing", "artifact", artifactID, "err", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (s *Server) Store() *store.Store { return s.store }
|
|
func (s *Server) Config() config.Config { return s.cfg }
|
|
|
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
p := r.URL.Path
|
|
if p == "/healthz" || !strings.HasPrefix(p, "/api/") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
token := bearerToken(r)
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
|
return
|
|
}
|
|
if strings.HasPrefix(p, "/api/v1/agent/") {
|
|
name, ok := s.agents.Authenticate(r.Context(), token)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "invalid agent token")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxAgent, name)))
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(token), []byte(s.cfg.ConsoleToken)) != 1 {
|
|
writeError(w, http.StatusUnauthorized, "invalid console token")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func bearerToken(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if rest, ok := strings.CutPrefix(h, "Bearer "); ok {
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
if q := r.URL.Query().Get("token"); q != "" && !strings.HasPrefix(r.URL.Path, "/api/") {
|
|
return q
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func TokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
|
slog.Error("write json", "err", err)
|
|
}
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
}
|