Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
415 lines
11 KiB
Go
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)
|
|
}
|