Files
kandev/apps/backend/internal/agent/settings/controller/agent_config.go
T
José Almeida 8922a9a2ee fix(backend): prefer local copilot CLI over npx at launch (#1384)
* fix(backend): prefer local copilot CLI over npx at launch

IsInstalled probed the copilot binary for the UI badge but launch always
ran `npx -y @github/copilot`, paying an npm registry round-trip every
start — minutes behind a private registry. Reuse the env-correct binary
check (standalone LookPath, SSH preflight probe) so when copilot is on
PATH the lifecycle runs it directly; containerized runtimes keep npx.

* fix(backend): make SSH native-binary probe non-fatal on transport error

The native-binary probe is an optimisation, not a requirement. A transient
SSH transport error (MaxSessions, reset) made "Verifying agent binary"
abort the launch, blaming copilot before npx was tried. Treat probe
transport errors as a miss and fall through to the required default-command
probe, which reports failures with proper context. Also call
NativeBinaryName() once.

* fix(backend): clear stale native-binary metadata before SSH probe

A pre-existing or stale MetadataKeyNativeBinary in req.Metadata could make
the command builder prefer copilot --acp on an npx-only host even when this
probe misses. Clear it up front so only a positive probe re-sets it.

* refactor(backend): use resolved RuntimeName for native-binary check

buildExecutionFromInstance re-derived the runtime from req.ExecutorType
while promoteWorkspaceExecution read execution.RuntimeName. Use the already
resolved execution.RuntimeName (set from rt.Name()) in both, so the two call
sites are consistent.

* fix(backend): reflect local copilot CLI in profile command preview

The profile setup command preview always rendered `npx -y @github/copilot`
because PreviewAgentCommand built the command without the native-binary
probe. The preview is not bound to an executor, so assume the default
standalone host and probe PATH (mirroring the standalone launch path): show
`copilot --acp` when the CLI is installed, npx otherwise.
2026-06-18 13:07:40 +01:00

313 lines
9.6 KiB
Go

package controller
import (
"context"
"database/sql"
"errors"
"fmt"
"os/exec"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/cliflags"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"go.uber.org/zap"
)
type UpdateAgentProfileMcpConfigRequest struct {
Enabled bool
Servers map[string]mcpconfig.ServerDef
Meta map[string]any
}
func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) {
config, err := c.mcpService.GetConfigByProfileID(ctx, profileID)
if err != nil {
if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
return nil, ErrAgentProfileNotFound
}
if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
return nil, ErrAgentMcpUnsupported
}
return nil, err
}
return &dto.AgentProfileMcpConfigDTO{
ProfileID: config.ProfileID,
Enabled: config.Enabled,
Servers: config.Servers,
Meta: config.Meta,
}, nil
}
func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigRequest) (*dto.AgentProfileMcpConfigDTO, error) {
config, err := c.mcpService.UpsertConfigByProfileID(ctx, profileID, &mcpconfig.ProfileConfig{
Enabled: req.Enabled,
Servers: req.Servers,
Meta: req.Meta,
})
if err != nil {
if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
return nil, ErrAgentProfileNotFound
}
if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
return nil, ErrAgentMcpUnsupported
}
return nil, err
}
return &dto.AgentProfileMcpConfigDTO{
ProfileID: config.ProfileID,
Enabled: config.Enabled,
Servers: config.Servers,
Meta: config.Meta,
}, nil
}
// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have
// MCP enabled by default. The kandev MCP server is automatically injected
// by agentctl at session creation time, so profiles don't need to explicitly
// configure it.
func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
agentList, err := c.repo.ListAgents(ctx)
if err != nil {
return err
}
for _, agent := range agentList {
if !agent.SupportsMCP {
continue
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return err
}
for _, profile := range profiles {
if err := c.ensureProfileMcpConfig(ctx, profile); err != nil {
return err
}
}
}
return nil
}
func (c *Controller) ensureProfileMcpConfig(ctx context.Context, profile *models.AgentProfile) error {
// Check if MCP config already exists
existingConfig, err := c.repo.GetAgentProfileMcpConfig(ctx, profile.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// Skip if config already exists (don't overwrite user settings)
if existingConfig != nil {
return nil
}
// Create default MCP config with MCP enabled but no servers configured.
// The kandev MCP server is automatically injected by agentctl when
// creating a new session. Users can add additional external MCP servers.
config := &models.AgentProfileMcpConfig{
ProfileID: profile.ID,
Enabled: true,
Servers: map[string]interface{}{},
Meta: map[string]interface{}{},
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := c.repo.UpsertAgentProfileMcpConfig(ctx, config); err != nil {
c.logger.Warn("failed to create default MCP config for profile",
zap.String("profile_id", profile.ID),
zap.String("profile_name", profile.Name),
zap.Error(err))
return nil
}
c.logger.Info("created default MCP config for profile",
zap.String("profile_id", profile.ID),
zap.String("profile_name", profile.Name))
return nil
}
// GetAgentLogo returns the SVG logo bytes for the given agent and variant.
func (c *Controller) GetAgentLogo(ctx context.Context, agentName string, variant agents.LogoVariant) ([]byte, error) {
ag, ok := c.agentRegistry.Get(agentName)
if !ok {
return nil, ErrAgentNotFound
}
data := ag.Logo(variant)
if len(data) == 0 {
return nil, ErrLogoNotAvailable
}
return data, nil
}
// resolvePermissionDefaults extracts the default permission values from agent settings.
func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) (autoApprove, allowIndexing, skipPermissions bool) {
if permSettings == nil {
return
}
if s, exists := permSettings[agents.PermissionKeyAutoApprove]; exists {
autoApprove = s.Default
}
if s, exists := permSettings["allow_indexing"]; exists {
allowIndexing = s.Default
}
if s, exists := permSettings["dangerously_skip_permissions"]; exists {
skipPermissions = s.Default
}
return
}
// CommandPreviewRequest contains the draft settings for command preview
type CommandPreviewRequest struct {
Model string
PermissionSettings map[string]bool
CLIPassthrough bool
CLIFlags []dto.CLIFlagDTO
}
// PreviewAgentCommand generates a preview of the CLI command that will be executed
func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) {
agentConfig, ok := c.agentRegistry.Get(agentName)
if !ok {
return nil, fmt.Errorf("agent type %q not found in registry", agentName)
}
// Resolve the user-configured cli_flags list into argv tokens. The
// launch path does the same via cliflags.Resolve; mirroring it here
// keeps the preview faithful to what the agent subprocess will see.
// Tolerate malformed entries silently — the preview is informational.
cliFlagTokens, _ := cliflags.Resolve(cliFlagsFromDTO(req.CLIFlags))
// Passthrough: BuildPassthroughCommand emits permission flags via Settings();
// the launch path (manager_passthrough.go) does not append CLIFlagTokens for
// passthrough, so the preview must match — otherwise permission flags that
// the legacy allow_indexing backfill also pushes into CLIFlags get rendered
// twice (e.g. Auggie's --allow-indexing).
// ACP: mirror lifecycle.CommandBuilder.BuildCommand by appending CLIFlagTokens
// after the agent's BuildCommand.
var cmd agents.Command
if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough {
cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{
Model: req.Model,
PermissionValues: req.PermissionSettings,
})
} else {
cmd = agentConfig.BuildCommand(agents.CommandOptions{
Model: req.Model,
PermissionValues: req.PermissionSettings,
CLIFlagTokens: cliFlagTokens,
PreferNativeBinary: previewPrefersNativeBinary(agentConfig),
})
if len(cliFlagTokens) > 0 {
cmd = cmd.With().Flag(cliFlagTokens...).Build()
}
}
return &dto.CommandPreviewResponse{
Supported: true,
Command: cmd.Args(),
CommandString: buildCommandString(cmd.Args()),
}, nil
}
// previewPrefersNativeBinary reports whether the command preview should show a
// NativeBinaryAgent's standalone CLI (e.g. "copilot --acp") instead of
// `npx -y <pkg>`. The preview is not bound to an executor, so it assumes the
// local (standalone) host — the default executor — and probes this machine's
// PATH, mirroring the standalone branch of lifecycle.preferNativeBinary.
// Containerized executors keep npx at launch regardless; the preview reflects
// the common local case.
func previewPrefersNativeBinary(agentConfig agents.Agent) bool {
nb, ok := agentConfig.(agents.NativeBinaryAgent)
if !ok {
return false
}
name := nb.NativeBinaryName()
if name == "" {
return false
}
_, err := exec.LookPath(name)
return err == nil
}
// FetchDynamicModels returns models and modes for an agent from the host
// utility capability cache populated by the ACP probe at boot. The refresh
// flag triggers a live Refresh() call against the warm host instance.
func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) {
if _, ok := c.agentRegistry.Get(agentName); !ok {
return nil, fmt.Errorf("agent %q not found", agentName)
}
if c.hostUtility == nil {
return &dto.DynamicModelsResponse{
AgentName: agentName,
Status: "not_configured",
}, nil
}
var caps hostutility.AgentCapabilities
if refresh {
var err error
caps, err = c.hostUtility.Refresh(ctx, agentName)
if err != nil {
s := err.Error()
return &dto.DynamicModelsResponse{
AgentName: agentName,
Status: string(hostutility.StatusFailed),
Error: &s,
}, nil
}
} else {
var ok bool
caps, ok = c.hostUtility.Get(agentName)
if !ok {
return &dto.DynamicModelsResponse{
AgentName: agentName,
Status: "not_configured",
}, nil
}
}
resp := &dto.DynamicModelsResponse{
AgentName: agentName,
Status: string(caps.Status),
CurrentModelID: caps.CurrentModelID,
CurrentModeID: caps.CurrentModeID,
Models: []dto.ModelEntryDTO{},
Modes: []dto.ModeEntryDTO{},
}
if caps.Error != "" {
e := caps.Error
resp.Error = &e
}
for _, m := range caps.Models {
resp.Models = append(resp.Models, dto.ModelEntryDTO{
ID: m.ID,
Name: m.Name,
Description: m.Description,
IsDefault: m.ID == caps.CurrentModelID,
Meta: m.Meta,
})
}
for _, m := range caps.Modes {
resp.Modes = append(resp.Modes, dto.ModeEntryDTO{
ID: m.ID,
Name: m.Name,
Description: m.Description,
Meta: m.Meta,
})
}
for _, c := range caps.Commands {
resp.Commands = append(resp.Commands, dto.CommandEntryDTO{
Name: c.Name,
Description: c.Description,
})
}
return resp, nil
}