feat: implement ACP permission request flow

- Add permission stream support to backend agentctl client
  - StreamPermissions() connects to permission WebSocket
  - RespondToPermission() sends responses via HTTP POST
  - PermissionNotification type for permission request data

- Connect lifecycle manager to permission stream
  - handlePermissionStream() receives permission requests
  - publishPermissionRequest() forwards to event bus
  - RespondToPermission() and RespondToPermissionByTaskID() methods

- Add WebSocket handler for permission.respond action
  - Validates task_id, pending_id, option_id
  - Routes response through orchestrator → executor → lifecycle manager

- Add permission WebSocket actions
  - ActionPermissionRequested for notifications
  - ActionPermissionRespond for user responses

- Update executor interface with RespondToPermissionByTaskID()
- Update mock implementations in tests
- Add permission response test to e2e-test.sh
- Fix e2e-test.sh ID extraction (strip newlines)

The full permission flow is now wired up:
agent → agentctl → backend → frontend → user → backend → agentctl → agent

Auto-approve mode is still enabled by default until frontend is ready.
This commit is contained in:
jcfs
2026-01-11 20:56:50 +00:00
parent 00c65268bc
commit 6dd0d7ffa4
22 changed files with 1204 additions and 56 deletions
+99
View File
@@ -0,0 +1,99 @@
// Package main is the entry point for the agentctl binary
// agentctl is a sidecar process that manages agent subprocess communication
// via HTTP API, bridging the agent's ACP protocol with the kandev backend.
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/kandev/kandev/internal/agentctl/api"
"github.com/kandev/kandev/internal/agentctl/config"
"github.com/kandev/kandev/internal/agentctl/process"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
func main() {
// Load configuration from environment
cfg := config.Load()
// Initialize logger
log, err := logger.NewLogger(logger.LoggingConfig{
Level: cfg.LogLevel,
Format: cfg.LogFormat,
OutputPath: "stdout",
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer log.Sync()
log.Info("starting agentctl",
zap.Int("port", cfg.Port),
zap.String("agent_command", cfg.AgentCommand),
zap.String("workdir", cfg.WorkDir),
zap.Bool("auto_start", cfg.AutoStart))
// Create process manager
procMgr := process.NewManager(cfg, log)
// Create HTTP API server
server := api.NewServer(cfg, procMgr, log)
// Start HTTP server
addr := fmt.Sprintf(":%d", cfg.Port)
httpServer := &http.Server{
Addr: addr,
Handler: server.Router(),
}
// Start server in goroutine
go func() {
log.Info("HTTP server starting", zap.String("address", addr))
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("HTTP server error", zap.Error(err))
os.Exit(1)
}
}()
// Auto-start agent if configured
if cfg.AutoStart {
log.Info("auto-starting agent process")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := procMgr.Start(ctx); err != nil {
log.Error("failed to auto-start agent", zap.Error(err))
}
cancel()
}
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down agentctl...")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Stop agent process
if err := procMgr.Stop(ctx); err != nil {
log.Error("error stopping agent process", zap.Error(err))
}
// Shutdown HTTP server
if err := httpServer.Shutdown(ctx); err != nil {
log.Error("error shutting down HTTP server", zap.Error(err))
}
log.Info("agentctl stopped")
}
+5
View File
@@ -573,6 +573,11 @@ func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID stri
}, nil
}
// RespondToPermissionByTaskID sends a response to a permission request for a task
func (a *lifecycleAdapter) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
return a.mgr.RespondToPermissionByTaskID(taskID, pendingID, optionID, cancelled)
}
// corsMiddleware returns a CORS middleware for WebSocket connections
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
+173 -8
View File
@@ -28,6 +28,29 @@ type Session struct {
mu sync.RWMutex
}
// PendingPermission represents a permission request waiting for user response
type PendingPermission struct {
ID string // Unique ID for this pending request
RPCID interface{} // JSON-RPC request ID from agent
InstanceID string // Agent instance
TaskID string // Task being worked on
SessionID string // ACP session ID
ToolCallID string // Tool call requesting permission
Title string // Human-readable title
Options []jsonrpc.PermissionOption // Available choices
ResponseCh chan *PermissionResponseMsg // Channel to receive user response
CreatedAt time.Time
}
// PermissionResponseMsg is the message sent by the user in response to a permission request
type PermissionResponseMsg struct {
OptionID string
Cancelled bool
}
// PermissionRequestHandler is called when an agent requests permission from the user
type PermissionRequestHandler func(req *PendingPermission)
// UpdateHandler is called when the agent sends session updates
type UpdateHandler func(instanceID, taskID string, updateType string, data json.RawMessage)
@@ -37,17 +60,25 @@ type SessionManager struct {
mu sync.RWMutex
eventBus bus.EventBus
logger *logger.Logger
// Handler for session updates
updateHandler UpdateHandler
// Handler for permission requests
permissionHandler PermissionRequestHandler
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
permissionMu sync.RWMutex
}
// NewSessionManager creates a new session manager
func NewSessionManager(eventBus bus.EventBus, log *logger.Logger) *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
eventBus: eventBus,
logger: log.WithFields(zap.String("component", "acp-session-manager")),
sessions: make(map[string]*Session),
pendingPermissions: make(map[string]*PendingPermission),
eventBus: eventBus,
logger: log.WithFields(zap.String("component", "acp-session-manager")),
}
}
@@ -56,6 +87,11 @@ func (m *SessionManager) SetUpdateHandler(handler UpdateHandler) {
m.updateHandler = handler
}
// SetPermissionHandler sets the handler for permission requests from agents
func (m *SessionManager) SetPermissionHandler(handler PermissionRequestHandler) {
m.permissionHandler = handler
}
// CreateSession creates a new ACP session for an agent instance
func (m *SessionManager) CreateSession(ctx context.Context, instanceID, taskID string, stdin io.WriteCloser, stdout io.Reader) error {
m.logger.Info("Creating ACP session",
@@ -395,8 +431,91 @@ func (m *SessionManager) handleRequestPermission(session *Session, id interface{
zap.String("title", reqParams.ToolCall.Title),
zap.Int("num_options", len(reqParams.Options)))
// Auto-approve: find the first "allow" option and select it
// In a real implementation, this would prompt the user or check policies
// If no permission handler is set, fall back to auto-approve
if m.permissionHandler == nil {
m.autoApprovePermission(session, id, &reqParams)
return
}
// Create a pending permission request
pendingID := fmt.Sprintf("%s-%d", session.InstanceID, time.Now().UnixNano())
pending := &PendingPermission{
ID: pendingID,
RPCID: id,
InstanceID: session.InstanceID,
TaskID: session.TaskID,
SessionID: reqParams.SessionID,
ToolCallID: reqParams.ToolCall.ToolCallID,
Title: reqParams.ToolCall.Title,
Options: reqParams.Options,
ResponseCh: make(chan *PermissionResponseMsg, 1),
CreatedAt: time.Now(),
}
// Store the pending request
m.permissionMu.Lock()
m.pendingPermissions[pendingID] = pending
m.permissionMu.Unlock()
// Notify the permission handler (which will send WebSocket notification to user)
m.permissionHandler(pending)
// Wait for user response in a goroutine to not block the read loop
go m.waitForPermissionResponse(session, pending)
}
// waitForPermissionResponse waits for user response and sends it to the agent
func (m *SessionManager) waitForPermissionResponse(session *Session, pending *PendingPermission) {
// Wait for response with a 5-minute timeout
timeout := time.After(5 * time.Minute)
select {
case resp := <-pending.ResponseCh:
m.logger.Info("Received permission response from user",
zap.String("pending_id", pending.ID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
var result jsonrpc.RequestPermissionResult
if resp.Cancelled {
result = jsonrpc.RequestPermissionResult{
Outcome: jsonrpc.PermissionOutcome{
Outcome: "cancelled",
},
}
} else {
result = jsonrpc.RequestPermissionResult{
Outcome: jsonrpc.PermissionOutcome{
Outcome: "selected",
OptionID: resp.OptionID,
},
}
}
if err := session.Client.SendResponse(pending.RPCID, result, nil); err != nil {
m.logger.Error("Failed to send permission response to agent", zap.Error(err))
}
case <-timeout:
m.logger.Warn("Permission request timed out, cancelling",
zap.String("pending_id", pending.ID))
result := jsonrpc.RequestPermissionResult{
Outcome: jsonrpc.PermissionOutcome{
Outcome: "cancelled",
},
}
session.Client.SendResponse(pending.RPCID, result, nil)
}
// Clean up
m.permissionMu.Lock()
delete(m.pendingPermissions, pending.ID)
m.permissionMu.Unlock()
}
// autoApprovePermission auto-approves a permission request (fallback behavior)
func (m *SessionManager) autoApprovePermission(session *Session, id interface{}, reqParams *jsonrpc.RequestPermissionParams) {
var selectedOptionID string
for _, opt := range reqParams.Options {
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
@@ -416,7 +535,6 @@ func (m *SessionManager) handleRequestPermission(session *Session, id interface{
zap.String("option_id", selectedOptionID))
}
// Send response
result := jsonrpc.RequestPermissionResult{
Outcome: jsonrpc.PermissionOutcome{
Outcome: "selected",
@@ -427,8 +545,55 @@ func (m *SessionManager) handleRequestPermission(session *Session, id interface{
if err := session.Client.SendResponse(id, result, nil); err != nil {
m.logger.Error("Failed to send permission response", zap.Error(err))
} else {
m.logger.Info("Sent permission response",
m.logger.Info("Sent auto-approved permission response",
zap.String("instance_id", session.InstanceID),
zap.String("selected_option", selectedOptionID))
}
}
// RespondToPermission sends a user's response to a pending permission request
func (m *SessionManager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
pending, exists := m.pendingPermissions[pendingID]
m.permissionMu.RUnlock()
if !exists {
return fmt.Errorf("pending permission request not found: %s", pendingID)
}
// Send response through channel (non-blocking)
select {
case pending.ResponseCh <- &PermissionResponseMsg{
OptionID: optionID,
Cancelled: cancelled,
}:
m.logger.Info("Sent permission response to waiting goroutine",
zap.String("pending_id", pendingID),
zap.String("option_id", optionID))
return nil
default:
return fmt.Errorf("permission request already responded to or timed out")
}
}
// GetPendingPermission returns a pending permission request by ID
func (m *SessionManager) GetPendingPermission(pendingID string) (*PendingPermission, bool) {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
pending, exists := m.pendingPermissions[pendingID]
return pending, exists
}
// GetPendingPermissionsForTask returns all pending permissions for a task
func (m *SessionManager) GetPendingPermissionsForTask(taskID string) []*PendingPermission {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
var result []*PendingPermission
for _, pending := range m.pendingPermissions {
if pending.TaskID == taskID {
result = append(result, pending)
}
}
return result
}
+124
View File
@@ -146,6 +146,36 @@ func (c *Client) Prompt(ctx context.Context, text string) (*PromptResponse, erro
// SessionNotification represents a session update from the agent
type SessionNotification = acp.SessionNotification
// PermissionOption represents a permission choice
type PermissionOption struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
}
// PermissionNotification is received when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []PermissionOption `json:"options"`
CreatedAt time.Time `json:"created_at"`
}
// PermissionResponse is the user's response to a permission request
type PermissionRespondRequest struct {
PendingID string `json:"pending_id"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// PermissionRespondResponse from agentctl
type PermissionRespondResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// StreamUpdates opens a WebSocket connection for streaming session updates
func (c *Client) StreamUpdates(ctx context.Context, handler func(acp.SessionNotification)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/acp/stream"
@@ -276,5 +306,99 @@ func (c *Client) CloseOutputStream() {
func (c *Client) Close() {
c.CloseACPStream()
c.CloseOutputStream()
c.ClosePermissionStream()
}
// StreamPermissions opens a WebSocket connection for streaming permission requests
func (c *Client) StreamPermissions(ctx context.Context, handler func(*PermissionNotification)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/acp/permissions/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("failed to connect to permission stream: %w", err)
}
c.mu.Lock()
c.permissionConn = conn
c.mu.Unlock()
c.logger.Info("connected to permission stream", zap.String("url", wsURL))
// Read messages in a goroutine
go func() {
defer func() {
c.mu.Lock()
c.permissionConn = nil
c.mu.Unlock()
conn.Close()
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Info("permission stream closed normally")
} else {
c.logger.Debug("permission stream error", zap.Error(err))
}
return
}
var notification PermissionNotification
if err := json.Unmarshal(message, &notification); err != nil {
c.logger.Warn("failed to parse permission notification", zap.Error(err))
continue
}
handler(&notification)
}
}()
return nil
}
// ClosePermissionStream closes the permission stream connection
func (c *Client) ClosePermissionStream() {
c.mu.Lock()
defer c.mu.Unlock()
if c.permissionConn != nil {
c.permissionConn.Close()
c.permissionConn = nil
}
}
// RespondToPermission sends a response to a permission request
func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID string, cancelled bool) error {
reqBody := PermissionRespondRequest{
PendingID: pendingID,
OptionID: optionID,
Cancelled: cancelled,
}
body, err := json.Marshal(reqBody)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/acp/permissions/respond", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var result PermissionRespondResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if !result.Success {
return fmt.Errorf("permission response failed: %s", result.Error)
}
return nil
}
@@ -21,9 +21,10 @@ type Client struct {
logger *logger.Logger
// WebSocket connections for streaming
outputConn *websocket.Conn
acpConn *websocket.Conn
mu sync.RWMutex
outputConn *websocket.Conn
acpConn *websocket.Conn
permissionConn *websocket.Conn
mu sync.RWMutex
}
// StatusResponse from agentctl
@@ -311,7 +311,10 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
// 3. Set up updates stream to receive session notifications
go m.handleUpdatesStream(instance)
// 4. Send the task prompt if provided
// 4. Set up permission stream to receive permission requests
go m.handlePermissionStream(instance)
// 5. Send the task prompt if provided
if taskDescription != "" {
m.logger.Info("sending ACP prompt",
zap.String("instance_id", instance.ID),
@@ -382,6 +385,74 @@ func (m *Manager) handleUpdatesStream(instance *AgentInstance) {
}
}
// handlePermissionStream handles streaming permission requests from the agent
func (m *Manager) handlePermissionStream(instance *AgentInstance) {
ctx := context.Background()
err := instance.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) {
m.handlePermissionNotification(instance, notification)
})
if err != nil {
m.logger.Error("failed to connect to permission stream",
zap.String("instance_id", instance.ID),
zap.Error(err))
}
}
// handlePermissionNotification processes incoming permission requests from the agent
func (m *Manager) handlePermissionNotification(instance *AgentInstance, notification *agentctl.PermissionNotification) {
m.logger.Info("received permission request",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title),
zap.Int("num_options", len(notification.Options)))
// Publish permission request event to the event bus
m.publishPermissionRequest(instance, notification)
}
// publishPermissionRequest publishes a permission request event
func (m *Manager) publishPermissionRequest(instance *AgentInstance, notification *agentctl.PermissionNotification) {
// Convert options to a serializable format
options := make([]map[string]interface{}, len(notification.Options))
for i, opt := range notification.Options {
options[i] = map[string]interface{}{
"option_id": opt.OptionID,
"name": opt.Name,
"kind": opt.Kind,
}
}
data := map[string]interface{}{
"type": "permission_request",
"task_id": instance.TaskID,
"agent_instance_id": instance.ID,
"pending_id": notification.PendingID,
"session_id": notification.SessionID,
"tool_call_id": notification.ToolCallID,
"title": notification.Title,
"options": options,
"created_at": notification.CreatedAt,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(instance.TaskID)
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
m.logger.Error("failed to publish permission_request event",
zap.String("instance_id", instance.ID),
zap.String("task_id", instance.TaskID),
zap.Error(err))
} else {
m.logger.Info("published permission_request event",
zap.String("task_id", instance.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
}
}
// handleSessionNotification processes incoming session notifications from the agent
func (m *Manager) handleSessionNotification(instance *AgentInstance, notification agentctl.SessionNotification) {
m.logger.Debug("received session notification",
@@ -1076,4 +1147,50 @@ func (m *Manager) performCleanup(ctx context.Context) {
}
}
}
}
// RespondToPermission sends a response to a permission request for an agent instance
func (m *Manager) RespondToPermission(instanceID, pendingID, optionID string, cancelled bool) error {
m.mu.RLock()
instance, exists := m.instances[instanceID]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("agent instance not found: %s", instanceID)
}
if instance.agentctl == nil {
return fmt.Errorf("agent instance has no agentctl client: %s", instanceID)
}
m.logger.Info("responding to permission request",
zap.String("instance_id", instanceID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return instance.agentctl.RespondToPermission(ctx, pendingID, optionID, cancelled)
}
// RespondToPermissionByTaskID sends a response to a permission request for a task
func (m *Manager) RespondToPermissionByTaskID(taskID, pendingID, optionID string, cancelled bool) error {
// Find the instance by task ID
m.mu.RLock()
var instance *AgentInstance
for _, inst := range m.instances {
if inst.TaskID == taskID {
instance = inst
break
}
}
m.mu.RUnlock()
if instance == nil {
return fmt.Errorf("no agent instance found for task: %s", taskID)
}
return m.RespondToPermission(instance.ID, pendingID, optionID, cancelled)
}
@@ -11,6 +11,11 @@ func DefaultAgents() []*AgentTypeConfig {
Tag: "latest",
WorkingDir: "/workspace",
RequiredEnv: []string{"AUGMENT_SESSION_AUTH"},
Env: map[string]string{
// Auto-approve permissions for now (until full permission flow is implemented)
// This allows the agent to proceed without waiting for user approval
"AGENTCTL_AUTO_APPROVE_PERMISSIONS": "true",
},
Mounts: []MountTemplate{
{Source: "{workspace}", Target: "/workspace", ReadOnly: false},
{Source: "{augment_sessions}", Target: "/root/.augment/sessions", ReadOnly: false},
+119 -3
View File
@@ -16,13 +16,39 @@ import (
// UpdateHandler is called when session updates are received from the agent
type UpdateHandler func(notification acp.SessionNotification)
// PermissionRequestHandler is called when the agent requests permission
// Returns the selected option ID, or empty string with cancelled=true to cancel
type PermissionRequestHandler func(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error)
// PermissionRequest represents a permission request from the agent
type PermissionRequest struct {
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []PermissionOption `json:"options"`
}
// PermissionOption represents a permission choice
type PermissionOption struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
}
// PermissionResponse is the user's response to a permission request
type PermissionResponse struct {
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// Client implements acp.Client interface and handles all agent requests
type Client struct {
logger *zap.Logger
workspaceRoot string
mu sync.RWMutex
updateHandler UpdateHandler
mu sync.RWMutex
updateHandler UpdateHandler
permissionHandler PermissionRequestHandler
}
// ClientOption configures a Client
@@ -49,6 +75,13 @@ func WithUpdateHandler(h UpdateHandler) ClientOption {
}
}
// WithPermissionHandler sets the handler for permission requests
func WithPermissionHandler(h PermissionRequestHandler) ClientOption {
return func(c *Client) {
c.permissionHandler = h
}
}
// NewClient creates a new ACP client implementation
func NewClient(opts ...ClientOption) *Client {
c := &Client{
@@ -68,8 +101,16 @@ func (c *Client) SetUpdateHandler(h UpdateHandler) {
c.updateHandler = h
}
// SetPermissionHandler sets the permission request handler (thread-safe)
func (c *Client) SetPermissionHandler(h PermissionRequestHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.permissionHandler = h
}
// RequestPermission handles permission requests from the agent
// Auto-approves by selecting the first "allow" option
// If a permission handler is set, it forwards the request to the handler.
// Otherwise, auto-approves by selecting the first "allow" option.
func (c *Client) RequestPermission(ctx context.Context, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
title := ""
if p.ToolCall.Title != nil {
@@ -91,6 +132,81 @@ func (c *Client) RequestPermission(ctx context.Context, p acp.RequestPermissionR
}, nil
}
// Check if we have a permission handler
c.mu.RLock()
handler := c.permissionHandler
c.mu.RUnlock()
if handler != nil {
// Forward to external handler (e.g., backend/user)
return c.forwardPermissionRequest(ctx, handler, p)
}
// Fall back to auto-approve
return c.autoApprovePermission(p)
}
// forwardPermissionRequest forwards the permission request to an external handler
func (c *Client) forwardPermissionRequest(ctx context.Context, handler PermissionRequestHandler, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
// Convert ACP types to our types
options := make([]PermissionOption, len(p.Options))
for i, opt := range p.Options {
options[i] = PermissionOption{
OptionID: string(opt.OptionId),
Name: opt.Name,
Kind: string(opt.Kind),
}
}
title := ""
if p.ToolCall.Title != nil {
title = *p.ToolCall.Title
}
req := &PermissionRequest{
SessionID: string(p.SessionId),
ToolCallID: string(p.ToolCall.ToolCallId),
Title: title,
Options: options,
}
c.logger.Info("forwarding permission request to handler",
zap.String("session_id", req.SessionID),
zap.String("tool_call_id", req.ToolCallID))
resp, err := handler(ctx, req)
if err != nil {
c.logger.Error("permission handler failed", zap.Error(err))
// On error, cancel the permission request
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Cancelled: &acp.RequestPermissionOutcomeCancelled{},
},
}, nil
}
if resp.Cancelled {
c.logger.Info("permission request cancelled by user")
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Cancelled: &acp.RequestPermissionOutcomeCancelled{},
},
}, nil
}
c.logger.Info("permission request approved by user",
zap.String("option_id", resp.OptionID))
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Selected: &acp.RequestPermissionOutcomeSelected{
OptionId: acp.PermissionOptionId(resp.OptionID),
},
},
}, nil
}
// autoApprovePermission auto-approves by selecting the first "allow" option
func (c *Client) autoApprovePermission(p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
// Find the first "allow" option
var selectedOption *acp.PermissionOption
for i := range p.Options {
+120
View File
@@ -233,3 +233,123 @@ func (s *Server) handleACPStreamWS(c *gin.Context) {
}
}
// PermissionRespondRequest is a request to respond to a permission request
type PermissionRespondRequest struct {
PendingID string `json:"pending_id" binding:"required"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// PermissionRespondResponse is the response to a permission respond call
type PermissionRespondResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func (s *Server) handlePermissionRespond(c *gin.Context) {
var req PermissionRespondRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PermissionRespondResponse{
Success: false,
Error: "invalid request: " + err.Error(),
})
return
}
s.logger.Info("received permission response",
zap.String("pending_id", req.PendingID),
zap.String("option_id", req.OptionID),
zap.Bool("cancelled", req.Cancelled))
if err := s.procMgr.RespondToPermission(req.PendingID, req.OptionID, req.Cancelled); err != nil {
s.logger.Error("failed to respond to permission", zap.Error(err))
c.JSON(http.StatusNotFound, PermissionRespondResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, PermissionRespondResponse{
Success: true,
})
}
// PendingPermissionResponse represents a pending permission for the API
type PendingPermissionResponse struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []PermissionOptionJSON `json:"options"`
CreatedAt string `json:"created_at"`
}
// PermissionOptionJSON is the JSON representation of a permission option
type PermissionOptionJSON struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"`
}
func (s *Server) handleGetPendingPermissions(c *gin.Context) {
pending := s.procMgr.GetPendingPermissions()
result := make([]PendingPermissionResponse, 0, len(pending))
for _, p := range pending {
options := make([]PermissionOptionJSON, len(p.Request.Options))
for i, opt := range p.Request.Options {
options[i] = PermissionOptionJSON{
OptionID: opt.OptionID,
Name: opt.Name,
Kind: opt.Kind,
}
}
result = append(result, PendingPermissionResponse{
PendingID: p.ID,
SessionID: p.Request.SessionID,
ToolCallID: p.Request.ToolCallID,
Title: p.Request.Title,
Options: options,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, result)
}
// handlePermissionStreamWS streams permission request notifications via WebSocket
func (s *Server) handlePermissionStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer conn.Close()
s.logger.Info("Permission stream WebSocket connected")
// Get the permission requests channel
permissionCh := s.procMgr.GetPermissionRequests()
// Stream permission notifications to WebSocket
for {
select {
case notification, ok := <-permissionCh:
if !ok {
return
}
data, err := json.Marshal(notification)
if err != nil {
s.logger.Error("failed to marshal permission notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Debug("WebSocket write error", zap.Error(err))
return
}
}
}
}
@@ -69,6 +69,11 @@ func (s *Server) setupRoutes() {
api.POST("/acp/prompt", s.handleACPPrompt)
api.GET("/acp/stream", s.handleACPStreamWS)
// Permission request handling
api.GET("/acp/permissions", s.handleGetPendingPermissions)
api.GET("/acp/permissions/stream", s.handlePermissionStreamWS)
api.POST("/acp/permissions/respond", s.handlePermissionRespond)
// Output streaming
api.GET("/output", s.handleGetOutput)
api.GET("/output/stream", s.handleOutputStreamWS)
@@ -27,6 +27,9 @@ type Config struct {
// Auto-start the agent on agentctl startup
AutoStart bool
// Auto-approve permission requests (for testing/CI)
AutoApprovePermissions bool
// Logging configuration
LogLevel string
LogFormat string
@@ -44,14 +47,15 @@ func Load() *Config {
defaultCmd := "auggie --acp --workspace-root " + workDir
cfg := &Config{
Port: getEnvInt("AGENTCTL_PORT", 9999),
AgentCommand: getEnv("AGENTCTL_AGENT_COMMAND", defaultCmd),
WorkDir: workDir,
AutoStart: getEnvBool("AGENTCTL_AUTO_START", false),
LogLevel: getEnv("AGENTCTL_LOG_LEVEL", "info"),
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
OutputBufferSize: getEnvInt("AGENTCTL_OUTPUT_BUFFER_SIZE", 1000),
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
Port: getEnvInt("AGENTCTL_PORT", 9999),
AgentCommand: getEnv("AGENTCTL_AGENT_COMMAND", defaultCmd),
WorkDir: workDir,
AutoStart: getEnvBool("AGENTCTL_AUTO_START", false),
AutoApprovePermissions: getEnvBool("AGENTCTL_AUTO_APPROVE_PERMISSIONS", false),
LogLevel: getEnv("AGENTCTL_LOG_LEVEL", "info"),
LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"),
OutputBufferSize: getEnvInt("AGENTCTL_OUTPUT_BUFFER_SIZE", 1000),
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
}
// Parse agent command into args
@@ -39,6 +39,24 @@ type errorWrapper struct {
err error
}
// PendingPermission represents a permission request waiting for user response
type PendingPermission struct {
ID string
Request *acpclient.PermissionRequest
ResponseCh chan *acpclient.PermissionResponse
CreatedAt time.Time
}
// PermissionNotification is sent when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []acpclient.PermissionOption `json:"options"`
CreatedAt time.Time `json:"created_at"`
}
// Manager manages the agent subprocess
type Manager struct {
cfg *config.Config
@@ -64,6 +82,13 @@ type Manager struct {
// Session update notifications
updatesCh chan acp.SessionNotification
// Permission request notifications (sent to backend)
permissionCh chan *PermissionNotification
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
permissionMu sync.RWMutex
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
@@ -75,10 +100,12 @@ type Manager struct {
// NewManager creates a new process manager
func NewManager(cfg *config.Config, log *logger.Logger) *Manager {
m := &Manager{
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
outputBuffer: NewOutputBuffer(cfg.OutputBufferSize),
updatesCh: make(chan acp.SessionNotification, 100),
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
outputBuffer: NewOutputBuffer(cfg.OutputBufferSize),
updatesCh: make(chan acp.SessionNotification, 100),
permissionCh: make(chan *PermissionNotification, 10),
pendingPermissions: make(map[string]*PendingPermission),
}
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
@@ -179,6 +206,7 @@ func (m *Manager) Start(ctx context.Context) error {
m.logger.Warn("updates channel full, dropping notification")
}
}),
acpclient.WithPermissionHandler(m.handlePermissionRequest),
)
// Create ACP SDK connection - it handles JSON-RPC communication
@@ -329,3 +357,172 @@ func (m *Manager) GetProcessInfo() map[string]interface{} {
return info
}
// handlePermissionRequest handles permission requests from the agent
// It stores the pending request and waits for a response from the backend
func (m *Manager) handlePermissionRequest(ctx context.Context, req *acpclient.PermissionRequest) (*acpclient.PermissionResponse, error) {
// Generate a unique ID for this permission request
pendingID := fmt.Sprintf("%s-%s-%d", req.SessionID, req.ToolCallID, time.Now().UnixNano())
m.logger.Info("handling permission request",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("tool_call_id", req.ToolCallID),
zap.String("title", req.Title),
zap.Bool("auto_approve", m.cfg.AutoApprovePermissions))
// If auto-approve is enabled, immediately approve with the first "allow" option
if m.cfg.AutoApprovePermissions {
return m.autoApprovePermission(req)
}
// Create pending permission with response channel
pending := &PendingPermission{
ID: pendingID,
Request: req,
ResponseCh: make(chan *acpclient.PermissionResponse, 1),
CreatedAt: time.Now(),
}
// Store pending permission
m.permissionMu.Lock()
m.pendingPermissions[pendingID] = pending
m.permissionMu.Unlock()
// Clean up when done
defer func() {
m.permissionMu.Lock()
delete(m.pendingPermissions, pendingID)
m.permissionMu.Unlock()
}()
// Send notification to backend via updates channel
// We use a custom notification type that the backend will recognize
m.sendPermissionNotification(pending)
// Wait for response with timeout
select {
case resp := <-pending.ResponseCh:
m.logger.Info("received permission response",
zap.String("pending_id", pendingID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
return resp, nil
case <-ctx.Done():
m.logger.Warn("permission request context cancelled",
zap.String("pending_id", pendingID))
return &acpclient.PermissionResponse{Cancelled: true}, nil
case <-time.After(5 * time.Minute):
m.logger.Warn("permission request timed out",
zap.String("pending_id", pendingID))
return &acpclient.PermissionResponse{Cancelled: true}, nil
}
}
// autoApprovePermission automatically approves a permission request
// by selecting the first "allow" option, or the first option if no allow option exists
func (m *Manager) autoApprovePermission(req *acpclient.PermissionRequest) (*acpclient.PermissionResponse, error) {
if len(req.Options) == 0 {
m.logger.Warn("no options available for auto-approve, cancelling")
return &acpclient.PermissionResponse{Cancelled: true}, nil
}
// Find the first "allow" option
var selectedOption *acpclient.PermissionOption
for i := range req.Options {
opt := &req.Options[i]
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
selectedOption = opt
break
}
}
// If no allow option, use the first option
if selectedOption == nil {
selectedOption = &req.Options[0]
}
m.logger.Info("auto-approving permission request",
zap.String("option_id", selectedOption.OptionID),
zap.String("option_name", selectedOption.Name),
zap.String("kind", selectedOption.Kind))
return &acpclient.PermissionResponse{
OptionID: selectedOption.OptionID,
}, nil
}
// sendPermissionNotification sends a permission request notification to the backend
func (m *Manager) sendPermissionNotification(pending *PendingPermission) {
notification := &PermissionNotification{
PendingID: pending.ID,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
Title: pending.Request.Title,
Options: pending.Request.Options,
CreatedAt: pending.CreatedAt,
}
m.logger.Info("sending permission notification to backend",
zap.String("pending_id", pending.ID),
zap.String("title", pending.Request.Title))
select {
case m.permissionCh <- notification:
// Sent successfully
default:
m.logger.Warn("permission channel full, dropping notification",
zap.String("pending_id", pending.ID))
}
}
// GetPermissionRequests returns the channel for permission request notifications
func (m *Manager) GetPermissionRequests() <-chan *PermissionNotification {
return m.permissionCh
}
// RespondToPermission responds to a pending permission request
func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
pending, ok := m.pendingPermissions[pendingID]
m.permissionMu.RUnlock()
if !ok {
return fmt.Errorf("pending permission not found: %s", pendingID)
}
m.logger.Info("responding to permission request",
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
// Send response (non-blocking since channel is buffered)
select {
case pending.ResponseCh <- &acpclient.PermissionResponse{
OptionID: optionID,
Cancelled: cancelled,
}:
return nil
default:
return fmt.Errorf("response channel full for pending permission: %s", pendingID)
}
}
// GetPendingPermissions returns all pending permission requests
func (m *Manager) GetPendingPermissions() []*PendingPermission {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
result := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
result = append(result, p)
}
return result
}
// GetPendingPermission returns a specific pending permission request
func (m *Manager) GetPendingPermission(pendingID string) (*PendingPermission, bool) {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
p, ok := m.pendingPermissions[pendingID]
return p, ok
}
@@ -19,6 +19,7 @@ import (
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
@@ -347,6 +348,16 @@ func (s *SimulatedAgentManagerClient) PromptAgent(ctx context.Context, agentInst
}, nil
}
// RespondToPermissionByTaskID responds to a permission request for a task
func (s *SimulatedAgentManagerClient) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
s.logger.Info("simulated: responding to permission",
zap.String("task_id", taskID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
return nil
}
// CompleteAgent marks an agent as completed
func (s *SimulatedAgentManagerClient) CompleteAgent(instanceID string) {
s.mu.Lock()
@@ -44,6 +44,9 @@ type AgentManagerClient interface {
// PromptAgent sends a prompt to a running agent
// Returns PromptResult indicating if the agent needs input
PromptAgent(ctx context.Context, agentInstanceID string, prompt string) (*PromptResult, error)
// RespondToPermission sends a response to a permission request
RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error
}
// LaunchAgentRequest contains parameters for launching an agent
@@ -228,6 +231,17 @@ func (e *Executor) Prompt(ctx context.Context, taskID string, prompt string) (*P
return e.agentManager.PromptAgent(ctx, agentInstanceID, prompt)
}
// RespondToPermission sends a response to a permission request for a task
func (e *Executor) RespondToPermission(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
e.logger.Info("responding to permission request",
zap.String("task_id", taskID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
return e.agentManager.RespondToPermissionByTaskID(ctx, taskID, pendingID, optionID, cancelled)
}
// GetExecution returns the current execution state for a task
func (e *Executor) GetExecution(taskID string) (*TaskExecution, bool) {
e.mu.RLock()
@@ -382,3 +396,13 @@ func (m *MockAgentManagerClient) PromptAgent(ctx context.Context, agentInstanceI
zap.Int("prompt_length", len(prompt)))
return &PromptResult{StopReason: "end_turn", NeedsInput: false}, nil
}
// RespondToPermissionByTaskID mocks responding to a permission request
func (m *MockAgentManagerClient) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
m.logger.Info("mock: responding to permission",
zap.String("task_id", taskID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
return nil
}
@@ -59,6 +59,10 @@ func (m *mockAgentManager) PromptAgent(ctx context.Context, agentInstanceID stri
return &executor.PromptResult{StopReason: "end_turn", NeedsInput: false}, nil
}
func (m *mockAgentManager) RespondToPermissionByTaskID(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
return nil
}
// testTaskRepository is an in-memory task repository for testing
type testTaskRepository struct {
tasks map[string]*v1.Task
@@ -293,6 +293,16 @@ func (s *Service) PromptTask(ctx context.Context, taskID string, prompt string)
}, nil
}
// RespondToPermission sends a response to a permission request for a task
func (s *Service) RespondToPermission(ctx context.Context, taskID, pendingID, optionID string, cancelled bool) error {
s.logger.Info("responding to permission request",
zap.String("task_id", taskID),
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
return s.executor.RespondToPermission(ctx, taskID, pendingID, optionID, cancelled)
}
// CompleteTask explicitly completes a task and stops its agent
func (s *Service) CompleteTask(ctx context.Context, taskID string) error {
s.logger.Info("completing task",
@@ -41,6 +41,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ws.ActionOrchestratorPrompt, h.PromptTask)
d.RegisterFunc(ws.ActionOrchestratorComplete, h.CompleteTask)
d.RegisterFunc(ws.ActionTaskLogs, h.GetTaskLogs)
d.RegisterFunc(ws.ActionPermissionRespond, h.RespondToPermission)
}
// GetStatus handles orchestrator.status action
@@ -294,3 +295,46 @@ func (h *Handlers) GetTaskLogs(ctx context.Context, msg *ws.Message) (*ws.Messag
Total: len(logs),
})
}
// PermissionRespondRequest is the payload for permission.respond
type PermissionRespondRequest struct {
TaskID string `json:"task_id"`
PendingID string `json:"pending_id"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// RespondToPermission handles permission.respond action
func (h *Handlers) RespondToPermission(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req PermissionRespondRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.PendingID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "pending_id is required", nil)
}
if !req.Cancelled && req.OptionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "option_id is required when not cancelled", nil)
}
h.logger.Info("responding to permission request",
zap.String("task_id", req.TaskID),
zap.String("pending_id", req.PendingID),
zap.String("option_id", req.OptionID),
zap.Bool("cancelled", req.Cancelled))
if err := h.service.RespondToPermission(ctx, req.TaskID, req.PendingID, req.OptionID, req.Cancelled); err != nil {
h.logger.Error("failed to respond to permission", zap.String("task_id", req.TaskID), zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to respond to permission: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"success": true,
"task_id": req.TaskID,
"pending_id": req.PendingID,
})
}
Binary file not shown.
+27
View File
@@ -94,3 +94,30 @@ type CreateCommentRequest struct {
AuthorType string `json:"author_type,omitempty"` // Defaults to "user" if not specified
RequestsInput bool `json:"requests_input,omitempty"`
}
// PermissionOption represents a permission choice presented to the user
type PermissionOption struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
}
// PermissionRequest represents an agent's request for user permission
type PermissionRequest struct {
RequestID string `json:"request_id"` // Unique ID for this request (JSON-RPC ID)
TaskID string `json:"task_id"` // Task the agent is working on
InstanceID string `json:"instance_id"` // Agent instance ID
SessionID string `json:"session_id"` // ACP session ID
ToolCallID string `json:"tool_call_id"` // Tool call requesting permission
Title string `json:"title"` // Human-readable title
Description string `json:"description,omitempty"` // Additional context
Options []PermissionOption `json:"options"` // Available choices
CreatedAt time.Time `json:"created_at"`
}
// PermissionResponse represents the user's response to a permission request
type PermissionResponse struct {
RequestID string `json:"request_id" binding:"required"` // The request being responded to
OptionID string `json:"option_id,omitempty"` // Selected option (if not cancelled)
Cancelled bool `json:"cancelled,omitempty"` // True if user cancelled
}
+4
View File
@@ -87,6 +87,10 @@ const (
ActionColumnDeleted = "column.deleted"
ActionCommentAdded = "comment.added"
ActionInputRequested = "input.requested"
// Permission request actions (agent -> user -> agent)
ActionPermissionRequested = "permission.requested" // Agent requesting permission
ActionPermissionRespond = "permission.respond" // User responding to permission request
)
// Error codes