mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 16:10:59 +00:00
75c570e951
- Secure CLI credential injection via AES-256-GCM encrypted env vars - API key management with fine-grained RBAC scopes - resolveAuth/requireAuth middleware across all 25+ HTTP handlers - In-memory API key cache with TTL, negative caching, pubsub invalidation - Sandbox-first execution (fails if unavailable, no silent fallback) - Credential scrubbing, constant-time token comparison, Admin-only CLI creds - SQL migration 000020: secure_cli_binaries + api_keys tables - 14 unit tests for cache and RBAC with race detector Closes #197
233 lines
6.1 KiB
Go
233 lines
6.1 KiB
Go
package http
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ResponsesHandler handles POST /v1/responses (OpenResponses protocol).
|
|
type ResponsesHandler struct {
|
|
agents *agent.Router
|
|
sessions store.SessionStore
|
|
token string
|
|
}
|
|
|
|
// NewResponsesHandler creates a handler for the responses endpoint.
|
|
func NewResponsesHandler(agents *agent.Router, sess store.SessionStore, token string) *ResponsesHandler {
|
|
return &ResponsesHandler{
|
|
agents: agents,
|
|
sessions: sess,
|
|
token: token,
|
|
}
|
|
}
|
|
|
|
type responsesRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []chatMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
}
|
|
|
|
func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Auth + RBAC check (gateway token or API key, operator required for POST)
|
|
auth := resolveAuth(r, h.token)
|
|
if !auth.Authenticated {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if !permissions.HasMinRole(auth.Role, permissions.RoleOperator) {
|
|
http.Error(w, `{"error":"permission denied: insufficient role"}`, http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Limit request body size to prevent DoS
|
|
const maxRequestBodySize = 1 << 20 // 1MB
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
|
|
|
var req responsesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"invalid JSON: %s"}`, err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Messages) == 0 {
|
|
http.Error(w, `{"error":"messages is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
agentID := extractAgentID(r, req.Model)
|
|
userID := extractUserID(r)
|
|
|
|
loop, err := h.agents.Get(agentID)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"agent not found: %s"}`, agentID), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var lastMessage string
|
|
for i := len(req.Messages) - 1; i >= 0; i-- {
|
|
if req.Messages[i].Role == "user" {
|
|
lastMessage = req.Messages[i].Content
|
|
break
|
|
}
|
|
}
|
|
if lastMessage == "" {
|
|
http.Error(w, `{"error":"no user message found"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Inject user_id into context for downstream stores/tools
|
|
ctx := r.Context()
|
|
if userID != "" {
|
|
ctx = store.WithUserID(ctx, userID)
|
|
}
|
|
|
|
runID := uuid.NewString()
|
|
responseID := "resp-" + runID[:8]
|
|
sessionSuffix := "responses-" + runID[:8]
|
|
if userID != "" {
|
|
sessionSuffix = "responses-" + userID + "-" + runID[:8]
|
|
}
|
|
sessionKey := sessions.SessionKey(agentID, sessionSuffix)
|
|
|
|
slog.Info("responses request", "agent", agentID, "stream", req.Stream, "user", userID)
|
|
|
|
if req.Stream {
|
|
h.handleStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID)
|
|
} else {
|
|
h.handleNonStream(w, r.WithContext(ctx), loop, runID, responseID, sessionKey, lastMessage, userID)
|
|
}
|
|
}
|
|
|
|
func (h *ResponsesHandler) handleNonStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, responseID, sessionKey, message, userID string) {
|
|
result, err := loop.Run(r.Context(), agent.RunRequest{
|
|
SessionKey: sessionKey,
|
|
Message: message,
|
|
Channel: "http",
|
|
ChatID: "api",
|
|
RunID: runID,
|
|
UserID: userID,
|
|
Stream: false,
|
|
})
|
|
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"agent error: %s"}`, err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"id": responseID,
|
|
"status": "completed",
|
|
"output": []map[string]any{{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": []map[string]string{{"type": "text", "text": result.Content}},
|
|
}},
|
|
}
|
|
|
|
if result.Usage != nil {
|
|
resp["usage"] = map[string]int{
|
|
"prompt_tokens": result.Usage.PromptTokens,
|
|
"completion_tokens": result.Usage.CompletionTokens,
|
|
"total_tokens": result.Usage.TotalTokens,
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (h *ResponsesHandler) handleStream(w http.ResponseWriter, r *http.Request, loop agent.Agent, runID, responseID, sessionKey, message, userID string) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// response.started
|
|
writeResponseEvent(w, flusher, map[string]any{
|
|
"type": "response.started",
|
|
"response": map[string]any{
|
|
"id": responseID,
|
|
"status": "in_progress",
|
|
"created_at": time.Now().Unix(),
|
|
},
|
|
})
|
|
|
|
result, err := loop.Run(r.Context(), agent.RunRequest{
|
|
SessionKey: sessionKey,
|
|
Message: message,
|
|
Channel: "http",
|
|
ChatID: "api",
|
|
RunID: runID,
|
|
UserID: userID,
|
|
Stream: true,
|
|
})
|
|
|
|
if err != nil {
|
|
// response.done with error
|
|
writeResponseEvent(w, flusher, map[string]any{
|
|
"type": "response.done",
|
|
"response": map[string]any{
|
|
"id": responseID,
|
|
"status": "failed",
|
|
"error": err.Error(),
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
// response.delta
|
|
writeResponseEvent(w, flusher, map[string]any{
|
|
"type": "response.delta",
|
|
"delta": map[string]any{
|
|
"type": "content",
|
|
"content": result.Content,
|
|
},
|
|
})
|
|
|
|
// response.done
|
|
doneResp := map[string]any{
|
|
"id": responseID,
|
|
"status": "completed",
|
|
}
|
|
if result.Usage != nil {
|
|
doneResp["usage"] = map[string]int{
|
|
"prompt_tokens": result.Usage.PromptTokens,
|
|
"completion_tokens": result.Usage.CompletionTokens,
|
|
"total_tokens": result.Usage.TotalTokens,
|
|
}
|
|
}
|
|
|
|
writeResponseEvent(w, flusher, map[string]any{
|
|
"type": "response.done",
|
|
"response": doneResp,
|
|
})
|
|
}
|
|
|
|
func writeResponseEvent(w http.ResponseWriter, flusher http.Flusher, data any) {
|
|
jsonData, _ := json.Marshal(data)
|
|
fmt.Fprintf(w, "data: %s\n\n", jsonData)
|
|
flusher.Flush()
|
|
}
|