Files
kandev/apps/backend/internal/agentctl/server/api/server.go
T
José Almeida 6cbf050fa1 fix: prevent agentctl OOM from unbounded diff generation in workspace tracker (#598)
* fix: prevent agentctl OOM from unbounded diff generation in workspace tracker

Add per-file size cap (10MB), binary detection (null-byte in 8KB),
diff output cap (256KB), and total budget (2MB) to workspace tracker
diff enrichment. Strip diff content from event bus path. Surface skip
reasons in frontend. Add pprof endpoints to agentctl.

* fix: address PR review feedback

Close stdout pipe on cmd.Start failure to prevent fd leak.
Limit untracked file read to maxDiffOutputSize (256KB) instead of
maxDiffFileSize (10MB) to reduce peak allocation from ~20MB to ~512KB.

* fix: add filepath.Clean to os.Stat call to satisfy CodeQL path-injection check

* fix: address second round of PR review feedback

Extract all DiffSkipReason strings as named constants. Add diff_skip_reason
to UncommittedFile type removing unsafe cast. Add unit tests for
isBinaryContent, totalDiffBytes, capDiffOutput truncation, and all
enrichUntrackedFileDiffs skip-reason paths.

* fix: use bytes.IndexByte for null-byte check clarity

* fix: show truncation banner when diff has partial content

* fix: revert diff stripping from event bus — frontend needs diffs from this path

E2E tests confirmed the frontend receives git status diffs through the
event bus (agentctl → backend WS → event bus → gateway → frontend).
Stripping diffs broke the changes panel. The per-file size caps in
workspace_git_diff.go already bound the data.

* fix: extract renderDiffContent to keep FileDiffSection under 100-line lint limit

* fix: correct onCommentRun type in renderDiffContent helper

* fix: format long destructuring line for prettier

* fix: move diffSkipReasonLabel to types.ts to keep review-diff-list under 600 lines

Co-authored-by: José Almeida <undefined@users.noreply.github.com>

* fix: coerce optional forceLoad to boolean for renderDiffContent

* fix: shorten className and comment to stay within prettier printWidth

* fix: apply prettier formatting to review-diff-list

* fix: correct off-by-one in untracked file line count

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: José Almeida <undefined@users.noreply.github.com>
2026-04-17 15:16:18 +01:00

429 lines
13 KiB
Go

// Package api provides the HTTP REST API for agentctl
package api
import (
"net/http"
"net/http/pprof"
"os"
"runtime"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/mcp"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/common/httpmw"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Server is the HTTP API server for a single agent instance.
type Server struct {
cfg *config.InstanceConfig
procMgr *process.Manager
mcpServer *mcp.Server
mcpBackendClient *mcp.ChannelBackendClient
logger *logger.Logger
router *gin.Engine
portProxies *portProxyCache
upgrader websocket.Upgrader
}
// NewServer creates a new API server for an agent instance.
// If mcpServer is provided, MCP routes will be registered.
// If mcpBackendClient is provided, the agent stream becomes bidirectional for MCP.
func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *mcp.Server, mcpBackendClient *mcp.ChannelBackendClient, log *logger.Logger) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{
cfg: cfg,
procMgr: procMgr,
mcpServer: mcpServer,
mcpBackendClient: mcpBackendClient,
logger: log.WithFields(zap.String("component", "api-server")),
router: gin.New(),
portProxies: newPortProxyCache(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for container-local communication
},
},
}
s.router.Use(httpmw.RequestLogger(s.logger, "agentctl-instance"))
s.setupRoutes()
return s
}
// Router returns the HTTP router
func (s *Server) Router() http.Handler {
return s.router
}
func (s *Server) setupRoutes() {
// Health check
s.router.GET("/health", s.handleHealth)
// Agent control
api := s.router.Group("/api/v1")
{
// Status and info
api.GET("/status", s.handleStatus)
api.GET("/info", s.handleInfo)
// Process control
api.POST("/agent/configure", s.handleAgentConfigure)
api.POST("/start", s.handleStart)
api.POST("/stop", s.handleStop)
// Agent stream: bidirectional WebSocket for agent events, MCP, and agent operations
// (initialize, session/new, session/load, prompt, cancel, stderr, permissions/respond)
api.GET("/agent/stream", s.handleAgentStreamWS)
// Unified workspace stream (git status, files, shell)
api.GET("/workspace/stream", s.handleWorkspaceStreamWS)
// Workspace file operations (simple HTTP)
api.GET("/workspace/tree", s.handleFileTree)
api.GET("/workspace/file/content", s.handleFileContent)
api.GET("/workspace/file/content-at-ref", s.handleFileContentAtRef)
api.POST("/workspace/file/content", s.handleFileUpdate)
api.POST("/workspace/file/create", s.handleFileCreate)
api.POST("/workspace/file/rename", s.handleFileRename)
api.DELETE("/workspace/file", s.handleFileDelete)
api.GET("/workspace/search", s.handleFileSearch)
// Shell access (HTTP endpoints only - streaming is via /workspace/stream)
api.GET("/shell/status", s.handleShellStatus)
api.GET("/shell/buffer", s.handleShellBuffer)
api.POST("/shell/start", s.handleShellStart)
// Per-terminal shell sessions (for remote executor multi-terminal support)
api.POST("/shell/terminal/start", s.handleShellTerminalStart)
api.GET("/shell/terminal/:id/stream", s.handleShellTerminalStreamWS)
api.GET("/shell/terminal/:id/buffer", s.handleShellTerminalBuffer)
api.DELETE("/shell/terminal/:id", s.handleShellTerminalStop)
// Process runner
api.POST("/processes/start", s.handleStartProcess)
api.POST("/processes/stop", s.handleStopProcess)
api.GET("/processes", s.handleListProcesses)
api.GET("/processes/:id", s.handleGetProcess)
// VS Code server management
api.POST("/vscode/start", s.handleVscodeStart)
api.POST("/vscode/stop", s.handleVscodeStop)
api.GET("/vscode/status", s.handleVscodeStatus)
api.POST("/vscode/open-file", s.handleVscodeOpenFile)
api.Any("/vscode/proxy", s.handleVscodeProxy)
api.Any("/vscode/proxy/*path", s.handleVscodeProxy)
// Port proxy - generic reverse proxy for any localhost port
api.Any("/port-proxy/:port", s.handlePortProxy)
api.Any("/port-proxy/:port/*path", s.handlePortProxy)
// Port listener detection
api.GET("/ports", s.handleListPorts)
// Git operations
api.POST("/git/pull", s.handleGitPull)
api.POST("/git/push", s.handleGitPush)
api.POST("/git/rebase", s.handleGitRebase)
api.POST("/git/merge", s.handleGitMerge)
api.POST("/git/abort", s.handleGitAbort)
api.POST("/git/commit", s.handleGitCommit)
api.POST("/git/stage", s.handleGitStage)
api.POST("/git/unstage", s.handleGitUnstage)
api.POST("/git/discard", s.handleGitDiscard)
api.POST("/git/create-pr", s.handleGitCreatePR)
api.POST("/git/revert-commit", s.handleGitRevertCommit)
api.POST("/git/rename-branch", s.handleGitRenameBranch)
api.POST("/git/reset", s.handleGitReset)
api.GET("/git/commit/:sha", s.handleGitShowCommit)
api.GET("/git/log", s.handleGitLog)
api.GET("/git/cumulative-diff", s.handleGitCumulativeDiff)
api.GET("/git/status", s.handleGitStatus)
}
// Utility agent routes
auxHandler := utility.NewHandler(s.cfg.WorkDir, s.logger.Zap())
auxHandler.RegisterRoutes(api)
// MCP routes (if MCP server is configured)
if s.mcpServer != nil {
s.mcpServer.RegisterRoutes(s.router)
api.PUT("/mcp/mode", s.handleSetMcpMode)
}
// pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true)
if os.Getenv("KANDEV_DEBUG_PPROF_ENABLED") == "true" {
s.registerPprofRoutes()
}
}
// Health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func (s *Server) handleHealth(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) registerPprofRoutes() {
g := s.router.Group("/debug/pprof")
g.GET("/", gin.WrapF(pprof.Index))
g.GET("/cmdline", gin.WrapF(pprof.Cmdline))
g.GET("/profile", gin.WrapF(pprof.Profile))
g.GET("/symbol", gin.WrapF(pprof.Symbol))
g.POST("/symbol", gin.WrapF(pprof.Symbol))
g.GET("/trace", gin.WrapF(pprof.Trace))
for _, name := range []string{"allocs", "block", "goroutine", "heap", "mutex", "threadcreate"} {
g.GET("/"+name, gin.WrapH(pprof.Handler(name)))
}
s.router.GET("/api/v1/debug/memory", func(c *gin.Context) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
c.JSON(http.StatusOK, gin.H{
"heap_alloc_mb": float64(m.HeapAlloc) / (1024 * 1024),
"heap_inuse_mb": float64(m.HeapInuse) / (1024 * 1024),
"heap_sys_mb": float64(m.HeapSys) / (1024 * 1024),
"heap_objects": m.HeapObjects,
"goroutines": runtime.NumGoroutine(),
"num_gc": m.NumGC,
"sys_mb": float64(m.Sys) / (1024 * 1024),
"stack_inuse_mb": float64(m.StackInuse) / (1024 * 1024),
})
})
s.logger.Info("pprof endpoints registered at /debug/pprof/")
}
// handleSetMcpMode changes the MCP tool mode for this instance.
func (s *Server) handleSetMcpMode(c *gin.Context) {
var req struct {
Mode string `json:"mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Mode != "task" && req.Mode != "config" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task' or 'config'"})
return
}
s.mcpServer.SetMode(req.Mode)
c.JSON(http.StatusOK, gin.H{"mode": req.Mode})
}
// Status response
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
ProcessInfo map[string]interface{} `json:"process_info"`
Uptime string `json:"uptime,omitempty"`
}
func (s *Server) handleStatus(c *gin.Context) {
c.JSON(http.StatusOK, StatusResponse{
AgentStatus: string(s.procMgr.Status()),
ProcessInfo: s.procMgr.GetProcessInfo(),
})
}
// Info response
type InfoResponse struct {
Version string `json:"version"`
AgentCommand string `json:"agent_command"`
WorkDir string `json:"work_dir"`
Port int `json:"port"`
}
func (s *Server) handleInfo(c *gin.Context) {
c.JSON(http.StatusOK, InfoResponse{
Version: "0.1.0",
AgentCommand: s.cfg.AgentCommand,
WorkDir: s.cfg.WorkDir,
Port: s.cfg.Port,
})
}
// Start request/response
type StartRequest struct {
// Future: could add options like custom command, env overrides
}
type StartResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Command string `json:"command,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStart(c *gin.Context) {
if err := s.procMgr.Start(c.Request.Context()); err != nil {
s.logger.Error("failed to start agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StartResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StartResponse{
Success: true,
Message: "agent started",
Command: s.procMgr.GetFinalCommand(),
})
}
// AgentConfigure request/response - configures the agent command before starting
type AgentConfigureRequest struct {
Command string `json:"command"`
ContinueCommand string `json:"continue_command,omitempty"` // For one-shot agents (Amp): command for follow-up prompts
Env map[string]string `json:"env,omitempty"`
ApprovalPolicy string `json:"approval_policy,omitempty"` // For Codex: "untrusted", "on-failure", "on-request", "never"
}
type AgentConfigureResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleAgentConfigure(c *gin.Context) {
var req AgentConfigureRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, AgentConfigureResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
if req.Command == "" {
c.JSON(http.StatusBadRequest, AgentConfigureResponse{
Success: false,
Error: "command is required",
})
return
}
if err := s.procMgr.Configure(req.Command, req.Env, req.ApprovalPolicy, req.ContinueCommand); err != nil {
s.logger.Error("failed to configure agent", zap.Error(err), zap.String("command", req.Command))
c.JSON(http.StatusInternalServerError, AgentConfigureResponse{
Success: false,
Error: err.Error(),
})
return
}
s.logger.Info("agent configured", zap.String("command", req.Command), zap.String("approval_policy", req.ApprovalPolicy))
c.JSON(http.StatusOK, AgentConfigureResponse{
Success: true,
Message: "agent configured",
})
}
// Stop request/response
type StopRequest struct {
Force bool `json:"force"`
}
type StopResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStop(c *gin.Context) {
if err := s.procMgr.Stop(c.Request.Context()); err != nil {
s.logger.Error("failed to stop agent", zap.Error(err))
c.JSON(http.StatusInternalServerError, StopResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, StopResponse{
Success: true,
Message: "agent stopped",
})
}
// ShellStatusResponse represents shell status
type ShellStatusResponse struct {
Available bool `json:"available"`
Running bool `json:"running"`
Pid int `json:"pid,omitempty"`
Shell string `json:"shell,omitempty"`
Cwd string `json:"cwd,omitempty"`
StartedAt string `json:"started_at,omitempty"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleShellStatus(c *gin.Context) {
shell := s.procMgr.Shell()
if shell == nil {
c.JSON(http.StatusOK, ShellStatusResponse{
Available: false,
Error: "shell not available",
})
return
}
status := shell.Status()
c.JSON(http.StatusOK, ShellStatusResponse{
Available: true,
Running: status.Running,
Pid: status.Pid,
Shell: status.Shell,
Cwd: status.Cwd,
StartedAt: status.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
// ShellMessage represents a shell I/O message
type ShellMessage struct {
Type string `json:"type"` // "input", "output", "ping", "pong", "exit"
Data string `json:"data,omitempty"`
Code int `json:"code,omitempty"`
}
// ShellBufferResponse is the response for GET /api/v1/shell/buffer
type ShellBufferResponse struct {
Data string `json:"data"`
}
func (s *Server) handleShellBuffer(c *gin.Context) {
shell := s.procMgr.Shell()
if shell == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "shell not available"})
return
}
buffered := shell.GetBufferedOutput()
c.JSON(http.StatusOK, ShellBufferResponse{
Data: string(buffered),
})
}
// handleShellStart starts the shell session without starting the agent process.
// This is used in passthrough mode where the agent runs directly via InteractiveRunner.
func (s *Server) handleShellStart(c *gin.Context) {
if err := s.procMgr.StartShell(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "shell started"})
}