From 9c8c02f4d8475db0bb8d6bd8cf39d612f91b1691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 4 Feb 2026 10:17:49 +0000 Subject: [PATCH] fix: wire permission handler to Copilot SDK (#161) --- .../server/adapter/copilot_adapter.go | 71 +++++++++++++++++++ apps/backend/pkg/copilot/client.go | 34 ++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go index a2f115f4e9..b4fbcbb14a 100644 --- a/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/copilot_adapter.go @@ -164,6 +164,9 @@ func (a *CopilotAdapter) Initialize(ctx context.Context) error { // Set event handler before starting a.client.SetEventHandler(a.handleEvent) + // Set permission handler before starting + a.client.SetPermissionHandler(a.handlePermissionRequest) + // Start the SDK client (connects to external server via TCP) if err := a.client.Start(ctx); err != nil { return fmt.Errorf("failed to start Copilot SDK client: %w", err) @@ -795,6 +798,74 @@ func (a *CopilotAdapter) signalCompletion(success bool, errMsg string) { } } +// handlePermissionRequest handles permission requests from the Copilot SDK. +// This is called by the SDK when the agent needs permission for an action. +func (a *CopilotAdapter) handlePermissionRequest( + request copilot.PermissionRequest, + invocation copilot.PermissionInvocation, +) (copilot.PermissionRequestResult, error) { + a.mu.RLock() + handler := a.permissionHandler + sessionID := a.sessionID + a.mu.RUnlock() + + // If no handler is set, auto-approve + if handler == nil { + a.logger.Debug("auto-approving permission (no handler)", + zap.String("kind", request.Kind), + zap.String("tool_call_id", request.ToolCallID)) + return copilot.PermissionRequestResult{Kind: "approved"}, nil + } + + // Build title from permission kind + title := fmt.Sprintf("Permission: %s", request.Kind) + if request.ToolCallID != "" { + title = fmt.Sprintf("Permission for tool %s: %s", request.ToolCallID, request.Kind) + } + + // Convert Copilot permission request to kandev format + permReq := &PermissionRequest{ + SessionID: sessionID, + ToolCallID: request.ToolCallID, + Title: title, + ActionType: request.Kind, + ActionDetails: request.Extra, + // Use tool_call_id as PendingID if available, otherwise generate one + PendingID: request.ToolCallID, + Options: []PermissionOption{ + {OptionID: "allow", Name: "Allow", Kind: "allow_once"}, + {OptionID: "deny", Name: "Deny", Kind: "reject_once"}, + }, + } + + a.logger.Info("requesting permission from user", + zap.String("kind", request.Kind), + zap.String("tool_call_id", request.ToolCallID), + zap.String("session_id", sessionID)) + + // Call the permission handler (blocking - waits for user response) + ctx := context.Background() + resp, err := handler(ctx, permReq) + if err != nil { + a.logger.Warn("permission handler error", zap.Error(err)) + return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil + } + + if resp == nil || resp.Cancelled { + a.logger.Info("permission denied (cancelled)") + return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil + } + + // Convert response + if resp.OptionID == "allow" { + a.logger.Info("permission approved") + return copilot.PermissionRequestResult{Kind: "approved"}, nil + } + + a.logger.Info("permission denied") + return copilot.PermissionRequestResult{Kind: "denied-interactively-by-user"}, nil +} + // RequiresProcessKill returns true because the Copilot CLI server doesn't exit on stdin close. // The CLI runs as an HTTP server that must be explicitly killed. func (a *CopilotAdapter) RequiresProcessKill() bool { diff --git a/apps/backend/pkg/copilot/client.go b/apps/backend/pkg/copilot/client.go index 79e8994306..a176a9ad63 100644 --- a/apps/backend/pkg/copilot/client.go +++ b/apps/backend/pkg/copilot/client.go @@ -25,6 +25,11 @@ type ( SessionConfig = copilot.SessionConfig MessageOptions = copilot.MessageOptions Data = copilot.Data + // Permission types + PermissionHandler = copilot.PermissionHandler + PermissionRequest = copilot.PermissionRequest + PermissionInvocation = copilot.PermissionInvocation + PermissionRequestResult = copilot.PermissionRequestResult ) // Re-export event type constants @@ -62,6 +67,10 @@ type Client struct { unsubscribe func() handlerMu sync.RWMutex + // Permission handler + permissionHandler PermissionHandler + permissionMu sync.RWMutex + // State sessionID string mu sync.RWMutex @@ -96,6 +105,13 @@ func (c *Client) SetEventHandler(handler func(SessionEvent)) { c.eventHandler = handler } +// SetPermissionHandler sets the handler for permission requests. +func (c *Client) SetPermissionHandler(handler PermissionHandler) { + c.permissionMu.Lock() + defer c.permissionMu.Unlock() + c.permissionHandler = handler +} + // Start initializes the Copilot SDK client. // When CLIUrl is configured, the SDK connects to an external CLI server via TCP. // Otherwise, the SDK spawns and manages the CLI process internally via stdio. @@ -199,10 +215,16 @@ func (c *Client) CreateSession(ctx context.Context) (string, error) { c.logger.Info("creating new session", zap.String("model", c.model)) + // Get permission handler + c.permissionMu.RLock() + permHandler := c.permissionHandler + c.permissionMu.RUnlock() + // Create session with configuration session, err := c.sdkClient.CreateSession(&copilot.SessionConfig{ - Model: c.model, - Streaming: true, + Model: c.model, + Streaming: true, + OnPermissionRequest: permHandler, }) if err != nil { return "", fmt.Errorf("failed to create session: %w", err) @@ -250,9 +272,15 @@ func (c *Client) ResumeSession(ctx context.Context, sessionID string) error { c.logger.Info("resuming session", zap.String("session_id", sessionID)) + // Get permission handler + c.permissionMu.RLock() + permHandler := c.permissionHandler + c.permissionMu.RUnlock() + // Use ResumeSessionWithOptions to enable streaming on resumed sessions session, err := c.sdkClient.ResumeSessionWithOptions(sessionID, &copilot.ResumeSessionConfig{ - Streaming: true, + Streaming: true, + OnPermissionRequest: permHandler, }) if err != nil { return fmt.Errorf("failed to resume session: %w", err)