// Package api provides the HTTP REST API for agentctl control server package api import ( "context" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/instance" commonconfig "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/common/ownershipperiod" "github.com/kandev/kandev/internal/common/subproc" "go.uber.org/zap" ) // ControlServer provides instance management endpoints on the control port. // It exposes the same API regardless of deployment context (Docker or host). // Each instance runs its own HTTP server with agent-specific endpoints. // adoptionOnlyPaths lists control-server routes where a superseded // credential remains valid on the adoption-only terms of // AC-EXECUTORS-CONTROL-OWNERSHIP-002.7/-002.9, until confirmed. Every other // authenticated route accepts only the current highest-numbered rotation's // credential. var adoptionOnlyPaths = map[string]bool{ "/api/v1/ownership/rotate": true, "/api/v1/ownership/shutdown": true, } type ControlServer struct { cfg *config.Config instMgr *instance.Manager logger *logger.Logger router *gin.Engine ownership *ownershipState credentials *credentialState shutdownRequested chan struct{} shutdownOnce sync.Once unownedPeriod time.Duration reaperStop chan struct{} reaperStopOnce sync.Once reaperWG sync.WaitGroup // decideUnownedShutdown is the reaper's shutdown decision, defaulted to // ownership.TryBeginShutdownIfUnownedFor. Tests override it to pin the // exact call the reaper makes rather than only observing its side // effects, so reverting to a stale two-call sequence (check then latch, // instead of the atomic check-and-latch) fails a test even though it // would still pass every other reaper assertion. decideUnownedShutdown func(time.Duration) bool } // NewControlServer creates a new ControlServer for instance management. func NewControlServer(cfg *config.Config, instMgr *instance.Manager, log *logger.Logger) *ControlServer { gin.SetMode(gin.ReleaseMode) csLogger := log.WithFields(zap.String("component", "control-server")) unownedPeriod, adjustments := ownershipperiod.Resolve(cfg.UnownedPeriod, cfg.IdleTimeout) for _, reason := range adjustments { csLogger.Warn(reason, zap.Duration("configured_unowned_period", cfg.UnownedPeriod), zap.Duration("idle_timeout", cfg.IdleTimeout), zap.Duration("effective_unowned_period", unownedPeriod)) } cs := &ControlServer{ cfg: cfg, instMgr: instMgr, logger: csLogger, router: gin.New(), ownership: newOwnershipState(), credentials: newCredentialState(cfg.AuthToken), shutdownRequested: make(chan struct{}), unownedPeriod: unownedPeriod, reaperStop: make(chan struct{}), } cs.decideUnownedShutdown = cs.ownership.TryBeginShutdownIfUnownedFor cs.router.Use(httpmw.RequestLogger(cs.logger, "agentctl-control")) cs.router.Use(controlCredentialAuth(cs.credentials, adoptionOnlyPaths, "/health", "/auth/handshake", "/identity", "/ownership/prove")) cs.setupRoutes() return cs } // Router returns the HTTP handler for the control server. func (m *ControlServer) Router() http.Handler { return m.router } // CredentialSource exposes this control server's single rotating credential // so a per-instance Server can authenticate its own requests and streams // against it instead of a static per-instance token (design 01 "Single // driver", AC-EXECUTORS-CONTROL-OWNERSHIP-002.6). Wire it via // Server.SetCredentialSource before an instance starts accepting requests. func (m *ControlServer) CredentialSource() InstanceCredentialSource { return m.credentials } // ShutdownRequested returns a channel that closes exactly once the // ownership-shutdown operation (AC-EXECUTORS-CONTROL-OWNERSHIP-002.9) has // been invoked. The run loop (cmd/agentctl/main.go) selects on this // alongside its OS-signal and parent-death triggers and runs the same // stop-every-instance-and-exit sequence. func (m *ControlServer) ShutdownRequested() <-chan struct{} { return m.shutdownRequested } // requestShutdown signals ShutdownRequested exactly once. Safe to call more // than once (a repeated ownership-shutdown call is a no-op beyond the first). func (m *ControlServer) requestShutdown() { m.shutdownOnce.Do(func() { close(m.shutdownRequested) }) } func (m *ControlServer) setupRoutes() { // Health check m.router.GET("/health", m.handleHealth) // Bootstrap handshake — nonce-authenticated, returns the self-generated auth token m.router.POST("/auth/handshake", m.handleHandshake) // Identity/capability — unauthenticated, decides adoption compatibility m.router.GET("/identity", m.handleIdentity) // Ownership proof — unauthenticated, establishes to an adopting backend // that this process holds the credential before that backend sends it m.router.POST("/ownership/prove", m.handleOwnershipProve) // Instance management API - same endpoints regardless of mode api := m.router.Group("/api/v1") api.POST("/instances", m.handleCreateInstance) api.GET("/instances", m.handleListInstances) api.GET("/instances/:id", m.handleGetInstance) api.DELETE("/instances/:id", m.handleDeleteInstance) api.GET("/instances/:id/turn-outcome", m.handleGetTurnOutcome) api.POST("/instances/:id/turn-outcome/ack", m.handleAckTurnOutcome) api.GET("/debug/subprocess-admission", m.handleSubprocessAdmission) api.GET("/ownership/details", m.handleOwnershipDetails) api.POST("/ownership/claim", m.handleOwnershipClaim) api.POST("/ownership/rotate", m.handleCredentialRotate) api.POST("/ownership/confirm", m.handleCredentialConfirm) api.POST("/ownership/shutdown", m.handleOwnershipShutdown) } func (m *ControlServer) handleSubprocessAdmission(c *gin.Context) { c.JSON(http.StatusOK, subproc.AdmissionSnapshot()) } func (m *ControlServer) handleHealth(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "timestamp": time.Now().UTC().Format(time.RFC3339), }) } // handleHandshake validates a bootstrap nonce and returns the self-generated auth token. // This endpoint is only available when agentctl was started with AGENTCTL_BOOTSTRAP_NONCE. // The nonce is one-shot: after a successful handshake, further attempts are rejected. func (m *ControlServer) handleHandshake(c *gin.Context) { var req struct { Nonce string `json:"nonce" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing or invalid nonce"}) return } token := m.cfg.ConsumeNonce(req.Nonce) if token == "" { // A rejected handshake has three distinct causes that otherwise look // identical from the outside: bootstrap nonce mode was never // configured, the received nonce doesn't match what agentctl started // with, or the configured nonce was already burned by an earlier // handshake. Logging both fingerprints lets a field 403 be attributed // to one of the three instead of guessed at. m.logger.Warn("handshake failed: invalid or already-consumed nonce", zap.Bool("bootstrap_nonce_configured", m.cfg.BootstrapNonceConfigured()), zap.String("configured_nonce_fingerprint", m.cfg.BootstrapNonceFingerprint()), zap.String("received_nonce_fingerprint", commonconfig.NonceFingerprint(req.Nonce))) c.JSON(http.StatusForbidden, gin.H{"error": "invalid or already-consumed nonce"}) return } m.ownership.Renew() m.logger.Info("bootstrap handshake completed, auth token issued", zap.String("nonce_fingerprint", commonconfig.NonceFingerprint(req.Nonce))) c.JSON(http.StatusOK, gin.H{"token": token}) } func (m *ControlServer) handleCreateInstance(c *gin.Context) { var req instance.CreateRequest if err := c.ShouldBindJSON(&req); err != nil { m.logger.Warn("invalid create instance request", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{ "error": "invalid request body: " + err.Error(), }) return } // Validate required fields if req.WorkspacePath == "" { c.JSON(http.StatusBadRequest, gin.H{ "error": "workspace_path is required", }) return } resp, err := m.instMgr.CreateInstance(c.Request.Context(), &req) if err != nil { m.logger.Error("failed to create instance", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{ "error": "failed to create instance: " + err.Error(), }) return } c.JSON(http.StatusCreated, resp) } func (m *ControlServer) handleListInstances(c *gin.Context) { instances := m.instMgr.ListInstances() c.JSON(http.StatusOK, gin.H{"instances": instances}) } func (m *ControlServer) handleGetInstance(c *gin.Context) { id := c.Param("id") inst, found := m.instMgr.GetInstance(id) if !found { c.JSON(http.StatusNotFound, gin.H{ "error": "instance not found", }) return } c.JSON(http.StatusOK, inst.Info()) } func (m *ControlServer) handleDeleteInstance(c *gin.Context) { id := c.Param("id") // First check if instance exists if _, found := m.instMgr.GetInstance(id); !found { c.JSON(http.StatusNotFound, gin.H{ "error": "instance not found", }) return } // Use a detached context so cleanup isn't canceled when the HTTP client disconnects. // The ControlClient has a 30s http.Client.Timeout; if it fires, the request context // is canceled, which cascades into force-killing processes mid-cleanup. stopCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() err := m.instMgr.StopInstance(stopCtx, id) if err != nil { m.logger.Error("failed to stop instance", zap.String("id", id), zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{ "error": "failed to stop instance: " + err.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "message": "instance stopped successfully", }) }