Files
goclaw/internal/http/responses.go
T
Goon 75c570e951 feat(security): credentialed exec + HTTP RBAC + API key cache (#197)
- 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
2026-03-15 20:13:18 +07:00

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()
}