Files
kandev/apps/backend/internal/agentctl/server/api/server.go
T
2026-08-24 21:05:02 +00:00

580 lines
20 KiB
Go

// Package api provides the HTTP REST API for agentctl
package api
import (
"encoding/json"
"net/http"
"net/http/pprof"
"os"
"runtime"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/agentctl/server/config"
"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"
lspinstaller "github.com/kandev/kandev/internal/lsp/installer"
"github.com/kandev/kandev/internal/mcp/plugintools"
mcpproviders "github.com/kandev/kandev/internal/mcp/providers"
"github.com/kandev/kandev/internal/mcp/server"
"github.com/kandev/kandev/internal/system/metrics"
"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
metricsCollector *metrics.Collector
lspInstaller lspInstallerRegistry
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(),
metricsCollector: metrics.NewCollector(),
lspInstaller: lspinstaller.NewRegistry("", log, lspinstaller.WithCommandRunner(procMgr)),
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"))
// Exempt paths from auth:
// - /health: liveness probe
// - /sse, /message, /mcp: MCP endpoints used by the agent subprocess which
// runs in the same trust boundary but does not possess the auth token.
s.router.Use(bearerTokenAuth(cfg.AuthToken, "/health", "/sse", "/message", "/mcp"))
// Validate X-Instance-ID so a client that holds a stale port (because
// the previous instance was deleted and the port recycled to a new
// instance) gets a clean 404 instead of accidentally configuring or
// starting the new instance's agent. Missing header is allowed for
// backward compatibility with curl/tests; mismatch is rejected.
s.router.Use(instanceIDGuard(cfg.InstanceID))
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)
api.GET("/system/metrics", s.handleSystemMetrics)
// Process control
api.POST("/agent/configure", s.handleAgentConfigure)
api.POST("/agent/managed-runtime/cache-repair", s.handleManagedRuntimeCacheRepair)
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)
api.GET("/lsp/stream", s.handleLSPStreamWS)
// Unified workspace stream (git status, files, shell)
api.GET("/workspace/stream", s.handleWorkspaceStreamWS)
// Workspace state (poll mode driven by gateway focus signal)
api.POST("/workspace/poll-mode", s.handleSetPollMode)
// Workspace rescan: triggered by the kandev backend after a new
// sibling worktree appears on disk (multi-branch add_branch flow).
// Rebuilds the per-repo tracker set so the new worktree's git/file
// events reach the UI without a session restart.
api.POST("/workspace/rescan", s.handleRescanWorkspace)
api.POST("/workspace/reconcile", s.handleReconcileWorkspace)
api.POST("/workspace/rebind", s.handleRebindWorkspace)
// Per-task base-branch map update: kandev backend hits this when
// the user picks a different "Compare against" branch via the
// changes-panel dropdown. Mutates the manager's BaseBranches map
// and triggers a fresh git-status emit so the UI updates without
// waiting for the next poll tick.
api.POST("/workspace/base-branches", s.handleSetBaseBranches)
// Provider-qualified comparison targets are authenticated internal state;
// the backend uses this route after association, retarget, and resume.
api.POST("/workspace/comparison-targets", s.handleSetComparisonTargets)
// 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)
api.GET("/workspace/content-search", s.handleWorkspaceContentSearch)
// Batched copy of files from the host (used by remote executors —
// Docker, Sprites — to seed the workspace with gitignored config
// after the in-container clone).
api.POST("/workspace/copy-files", s.handleWorkspaceCopyFiles)
api.POST("/attachments/materialize", s.handleMaterializeAttachment)
api.POST("/workspace/diagnostics/:id", s.handleWorkspaceDiagnostics)
api.POST("/workspace/materialize-repository", s.handleWorkspaceMaterializeRepository)
api.POST("/workspace/materialize-repository/remove", s.handleWorkspaceRemoveMaterializedRepository)
// 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/push-preflight", s.handleGitPushPreflight)
api.POST("/git/contribution/replace", s.handleGitReplaceContribution)
api.POST("/git/contribution/use", s.handleGitUseContribution)
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)
api.GET("/git/status/multi", s.handleGitStatusMulti)
}
// 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)
api.PUT("/mcp/providers", s.handleSetMcpProviders)
api.PUT("/mcp/plugin-tools", s.handleSetPluginTools)
}
// pprof + memory stats (enabled via KANDEV_DEBUG_PPROF_ENABLED=true)
if os.Getenv("KANDEV_DEBUG_PPROF_ENABLED") == "true" { //nolint:goconst // env-var check, not a query param
s.registerPprofRoutes()
}
// Dev-only live tail of a session's recent ACP frames from an in-memory
// ring buffer (zero disk growth). Complements the file sink for
// investigating a currently-stuck session. Frames carry full prompt/tool
// content, so require BOTH frame logging and dev mode to be on — message
// logging alone (e.g. someone debugging a non-dev deployment) must not
// expose this endpoint.
if acpDebugTailEnabled() {
s.router.GET("/api/v1/debug/acp/:session", s.handleACPRingTail)
}
if acpDebugExportEnabled() {
s.router.GET("/api/v1/debug/acp/:session/export", s.handleACPDebugExport)
}
}
// acpDebugTailEnabled gates the ACP live-tail endpoint on both ACP frame
// logging and dev mode. Read live (like the pprof gate) so it is testable.
func acpDebugTailEnabled() bool {
return os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES") == "true" && //nolint:goconst // env-var values, not query params
os.Getenv("KANDEV_DEBUG_DEV_MODE") == "true"
}
// handleACPRingTail returns the most recent normalized ACP events for a
// session from the in-memory ring buffer. Query param n caps the count
// (default 200, max 1000).
func (s *Server) handleACPRingTail(c *gin.Context) {
const (
defaultACPRingTailCount = 200
maxACPRingTailCount = 1000
)
session := c.Param("session")
n := defaultACPRingTailCount
// Bound the untrusted n at the source so it can't drive an oversized
// allocation downstream (the ring tail clamps to its size too, but
// keeping the limit explicit here is the defensive default).
if v := c.Query("n"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 && parsed <= maxACPRingTailCount {
n = parsed
}
}
events := shared.ACPRingTail(session, n)
if events == nil {
events = []json.RawMessage{}
}
c.JSON(http.StatusOK, gin.H{
"session": session,
"count": len(events),
"events": events,
})
}
// 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 != mcp.ModeTask && req.Mode != mcp.ModeTaskTitlePending && req.Mode != mcp.ModeConfig && req.Mode != mcp.ModeOffice {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'task-title-pending', 'config', or 'office'"})
return
}
s.mcpServer.SetMode(req.Mode)
c.JSON(http.StatusOK, gin.H{"mode": req.Mode})
}
// handleSetMcpProviders replaces the provider capabilities advertised by the
// task-mode MCP server. Unknown providers are ignored by the MCP boundary.
func (s *Server) handleSetMcpProviders(c *gin.Context) {
var req struct {
Providers []string `json:"mcp_providers"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
providers := mcpproviders.Normalize(req.Providers)
s.mcpServer.SetProviders(providers)
c.JSON(http.StatusOK, gin.H{"mcp_providers": providers})
}
func (s *Server) handleSetPluginTools(c *gin.Context) {
var snapshot plugintools.Snapshot
if err := c.ShouldBindJSON(&snapshot); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.mcpServer.SetPluginTools(snapshot); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"generation": snapshot.Generation, "revision": snapshot.Revision})
}
// 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"`
AgentArgs optionalArgs `json:"agent_args"`
ContinueCommand string `json:"continue_command,omitempty"` // For one-shot agents (Amp): command for follow-up prompts
ContinueArgs optionalArgs `json:"continue_args"`
Env map[string]string `json:"env,omitempty"`
ApprovalPolicy string `json:"approval_policy,omitempty"` // For Codex: "untrusted", "on-failure", "on-request", "never"
}
type optionalArgs struct {
Present bool
Args []string
}
func (a *optionalArgs) UnmarshalJSON(data []byte) error {
a.Present = true
return json.Unmarshal(data, &a.Args)
}
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.AgentArgs.Present && req.Command == "" {
c.JSON(http.StatusBadRequest, AgentConfigureResponse{
Success: false,
Error: "command is required",
})
return
}
if err := s.procMgr.Configure(req.Command, req.AgentArgs.Args, req.AgentArgs.Present, req.Env, req.ApprovalPolicy, req.ContinueCommand, req.ContinueArgs.Args, req.ContinueArgs.Present); 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"})
}