rdev/internal/claudebox/server.go
jordan b6e778d5ab
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(git): harden git flow for concurrent SDLC stress test failures
5 fixes from stress test analysis:

1. CRITICAL: Add pull-before-push to claudebox GitOperations.CommitAndPush,
   matching the fix already in PodGitOperations (prevents push rejections
   when concurrent builds advance the remote).

2. HIGH: Extract ResetToMain into PodGitOperations as a shared public method.
   Wire into BuildExecutor after CloneRepo and update SDLCTaskExecutor to
   use the shared method. Prevents builds from running on wrong branch when
   worker pods are reused across tasks.

3. HIGH: Make branch create push failure fatal with retry+rollback in
   cmd/sdlc/cmd_branch.go. Prevents orphaned .sdlc/ state that causes
   merge failures after completing all 10 SDLC phases.

4. MEDIUM: Shell-escape token in credential helpers (both PodGitOperations
   and claudebox GitOperations) to prevent shell injection via tokens
   containing special characters.

5. MEDIUM: Add GitResetToMain to claudebox sidecar (git.go implementation,
   server.go endpoint, client.go HTTP method) and wire into
   HTTPSDLCTaskExecutor for the HTTP sidecar path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:57:27 -07:00

415 lines
11 KiB
Go

// Package claudebox provides HTTP server and handlers for the claudebox sidecar.
// This package enables HTTP-based execution of Claude Code, git, and SDLC operations
// instead of kubectl exec.
package claudebox
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/pkg/api"
)
// writeRawJSON writes a raw JSON response without the rdev-api wrapper.
// The claudebox sidecar uses direct JSON responses, not the {data, meta} format.
func writeRawJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// Server handles HTTP requests for claudebox operations.
type Server struct {
executor *Executor
gitOps *GitOperations
sdlcRunner *SDLCRunner
logger *slog.Logger
}
// ServerConfig holds configuration for the claudebox server.
type ServerConfig struct {
Executor *Executor
GitOps *GitOperations
SDLCRunner *SDLCRunner
Logger *slog.Logger
}
// NewServer creates a new claudebox HTTP server.
func NewServer(cfg ServerConfig) *Server {
return &Server{
executor: cfg.Executor,
gitOps: cfg.GitOps,
sdlcRunner: cfg.SDLCRunner,
logger: cfg.Logger,
}
}
// Mount registers server routes on the router.
func (s *Server) Mount(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Post("/execute", s.handleExecute)
r.Post("/execute/stream", s.handleExecuteStream)
r.Post("/git/clone", s.handleGitClone)
r.Post("/git/commit-and-push", s.handleGitCommitAndPush)
r.Post("/git/reset-to-main", s.handleGitResetToMain)
r.Get("/git/status", s.handleGitStatus)
r.Post("/sdlc", s.handleSDLC)
}
// HealthResponse is the health check response.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
WorkDir string `json:"work_dir"`
}
// handleHealth returns server health status.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
WorkDir: s.executor.workDir,
}
writeRawJSON(w, http.StatusOK, resp)
}
// ExecuteRequest is the request to execute Claude Code.
type ExecuteRequest struct {
Prompt string `json:"prompt"`
AllowedTools []string `json:"allowed_tools,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Timeout int `json:"timeout_seconds,omitempty"` // seconds
Metadata map[string]string `json:"metadata,omitempty"`
}
// ExecuteResponse is the response from executing Claude Code.
type ExecuteResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
FinalOutput string `json:"final_output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// handleExecute runs Claude Code and returns the complete result.
func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
log.Info("executing Claude Code", "prompt_len", len(req.Prompt))
result := s.executor.Execute(ctx, &req)
resp := ExecuteResponse{
Success: result.Success,
Output: result.Output,
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
SessionID: result.SessionID,
FinalOutput: result.FinalOutput,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// StreamEvent is an SSE event for streaming execution.
type StreamEvent struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Stream string `json:"stream,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Data map[string]any `json:"data,omitempty"`
Timestamp string `json:"timestamp"`
}
// handleExecuteStream runs Claude Code and streams events via SSE.
func (s *Server) handleExecuteStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
// Set up SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
api.WriteInternalError(w, r, "streaming not supported")
return
}
log.Info("starting streaming execution", "prompt_len", len(req.Prompt))
// Stream events via callback
eventCh := make(chan StreamEvent, 100)
go func() {
defer close(eventCh)
s.executor.ExecuteStream(ctx, &req, func(evt StreamEvent) {
select {
case eventCh <- evt:
case <-ctx.Done():
}
})
}()
// Write events to client
for evt := range eventCh {
data, err := json.Marshal(evt)
if err != nil {
log.Warn("failed to marshal event", logging.FieldError, err)
continue
}
_, writeErr := w.Write([]byte("data: " + string(data) + "\n\n"))
if writeErr != nil {
log.Debug("client disconnected during stream")
return
}
flusher.Flush()
}
}
// GitCloneRequest is the request to clone a repository.
type GitCloneRequest struct {
CloneURL string `json:"clone_url"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCloneResponse is the response from cloning.
type GitCloneResponse struct {
Success bool `json:"success"`
Cloned bool `json:"cloned"` // true if cloned, false if already existed
Error string `json:"error,omitempty"`
}
// handleGitClone clones or updates a git repository.
func (s *Server) handleGitClone(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCloneRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.CloneURL == "" {
api.WriteBadRequest(w, r, "clone_url is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CloneRepo(ctx, workDir, req.CloneURL)
resp := GitCloneResponse{
Success: result.Error == nil,
Cloned: result.Cloned,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// GitCommitAndPushRequest is the request to commit and push changes.
type GitCommitAndPushRequest struct {
Message string `json:"message"`
Push bool `json:"push"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCommitAndPushResponse is the response from commit and push.
type GitCommitAndPushResponse struct {
Success bool `json:"success"`
HasChanges bool `json:"has_changes"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Pushed bool `json:"pushed"`
Error string `json:"error,omitempty"`
}
// handleGitCommitAndPush commits and optionally pushes changes.
func (s *Server) handleGitCommitAndPush(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCommitAndPushRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Message == "" {
api.WriteBadRequest(w, r, "message is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CommitAndPush(ctx, workDir, req.Message, req.Push)
resp := GitCommitAndPushResponse{
Success: result.Error == nil,
HasChanges: result.HasChanges,
CommitSHA: result.CommitSHA,
FilesChanged: result.FilesChanged,
Pushed: result.Pushed,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// GitResetToMainRequest is the request to reset the workspace to main.
type GitResetToMainRequest struct {
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitResetToMainResponse is the response from resetting to main.
type GitResetToMainResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// handleGitResetToMain resets the workspace to the main branch.
func (s *Server) handleGitResetToMain(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitResetToMainRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
err := s.gitOps.ResetToMain(ctx, workDir)
resp := GitResetToMainResponse{
Success: err == nil,
}
if err != nil {
resp.Error = err.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}
// GitStatusResponse is the response from git status.
type GitStatusResponse struct {
IsRepo bool `json:"is_repo"`
HasChanges bool `json:"has_changes"`
ChangedFiles []string `json:"changed_files,omitempty"`
Branch string `json:"branch,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGitStatus returns the git status of the workspace.
func (s *Server) handleGitStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workDir := r.URL.Query().Get("work_dir")
if workDir == "" {
workDir = s.gitOps.workDir
}
status, err := s.gitOps.Status(ctx, workDir)
if err != nil {
writeRawJSON(w, http.StatusOK, GitStatusResponse{
IsRepo: false,
Error: err.Error(),
})
return
}
writeRawJSON(w, http.StatusOK, status)
}
// SDLCRequest is the request to run an SDLC command.
type SDLCRequest struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// SDLCResponse is the response from running an SDLC command.
type SDLCResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
Data json.RawMessage `json:"data,omitempty"` // Parsed JSON from sdlc --json output
Error string `json:"error,omitempty"`
}
// handleSDLC runs an SDLC CLI command.
func (s *Server) handleSDLC(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SDLCRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Command == "" {
api.WriteBadRequest(w, r, "command is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.sdlcRunner.workDir
}
result := s.sdlcRunner.Run(ctx, workDir, req.Command, req.Args)
resp := SDLCResponse{
Success: result.Success,
Output: result.Output,
Data: result.Data,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
writeRawJSON(w, http.StatusOK, resp)
}