Files
kandev/apps/backend/internal/agent/settings/controller/profile_crud.go
T
2026-09-25 10:31:58 +01:00

1452 lines
54 KiB
Go

package controller
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strings"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/settings/cliflags"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/profileconfig"
"github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/acpprovider"
"github.com/kandev/kandev/internal/secrets"
)
const dynamicProfileKind = "dynamic"
func cursorMCPAuthEnabled(value *bool) bool {
return value == nil || *value
}
type CreateProfileRequest struct {
AgentID string
Name string
Model string
FallbackModel string
AutoFallback bool
RequireExactModel bool
Mode string
ConfigOptions map[string]string
AllowIndexing bool
AutoApprove bool
CLIPassthrough bool
// CLIFlags is the explicit list to persist. When nil, the profile is
// seeded from the agent's curated PermissionSettings() list so a fresh
// profile opens with the agent's recommended flags (all disabled by
// default unless the curated entry specifies Default: true).
CLIFlags []dto.CLIFlagDTO
EnvVars []dto.ProfileEnvVarDTO
// CommandPrefix is an optional launcher prefix prepended to the agent
// command (e.g. "greywall --"). Shell-tokenised at launch time.
CommandPrefix string
// ProviderKind / ProviderBaseURL / ProviderAPIKeySecretID configure an
// injected OpenAI-compatible provider. Empty ProviderKind = native.
ProviderKind string
ProviderBaseURL string
ProviderAPIKeySecretID string
CursorMCPAuthEnabled *bool
Dynamic *dto.DynamicAgentProfileDTO
}
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
// Model is optional — the profile reconciler fills it from the host
// utility probe cache on boot, and session start applies it via
// ACP model selection. An empty model means "use the agent's default".
agent, err := c.repo.GetAgent(ctx, req.AgentID)
if err != nil {
return nil, err
}
agentConfig, agOk := c.agentRegistry.Get(agent.Name)
if !agOk {
return nil, fmt.Errorf("unknown agent: %s", agent.Name)
}
if err := validateRequireExactModelPolicy(req.Model, req.RequireExactModel, req.CLIPassthrough, agent.Name == agents.DynamicAgentID); err != nil {
return nil, err
}
displayName, err := c.resolveDisplayName(agentConfig, agent.Name)
if err != nil {
return nil, err
}
if agent.Name == agents.DynamicAgentID {
return c.createDynamicProfile(ctx, agent, displayName, req)
}
cliFlags := cliFlagsFromDTO(req.CLIFlags)
if req.CLIFlags == nil {
cliFlags = seedCLIFlags(agentConfig)
} else if err := validateCLIFlagDTOs(req.CLIFlags); err != nil {
return nil, err
}
if err := validateProfileEnvVarDTOs(req.EnvVars); err != nil {
return nil, err
}
if err := c.validateGlobalSecretRefs(ctx, req.EnvVars); err != nil {
return nil, err
}
if err := validateCommandPrefix(req.CommandPrefix); err != nil {
return nil, err
}
profile := &models.AgentProfile{
AgentID: req.AgentID,
Name: req.Name,
AgentDisplayName: displayName,
Model: req.Model,
FallbackModel: strings.TrimSpace(req.FallbackModel),
AutoFallback: req.AutoFallback,
RequireExactModel: req.RequireExactModel,
Mode: req.Mode,
ConfigOptions: profileconfig.SanitizeConfigOptions(req.ConfigOptions),
AllowIndexing: req.AllowIndexing,
AutoApprove: req.AutoApprove,
CLIPassthrough: req.CLIPassthrough,
CursorMCPAuthEnabled: cursorMCPAuthEnabled(req.CursorMCPAuthEnabled),
Enabled: true,
CLIFlags: cliFlags,
EnvVars: envVarsFromDTO(req.EnvVars),
CommandPrefix: strings.TrimSpace(req.CommandPrefix),
ProviderKind: req.ProviderKind,
ProviderBaseURL: req.ProviderBaseURL,
ProviderAPIKeySecretID: req.ProviderAPIKeySecretID,
UserModified: true,
}
if err := c.normalizeProviderConfig(ctx, profile, agent.Name); err != nil {
return nil, err
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
result := toProfileDTO(profile)
c.decorateProviderSupport(agent.Name, &result)
return &result, nil
}
func (c *Controller) createDynamicProfile(
ctx context.Context,
agent *models.Agent,
displayName string,
req CreateProfileRequest,
) (*dto.AgentProfileDTO, error) {
if !c.dynamicAgentRoutingEnabled {
return nil, ErrDynamicAgentRoutingDisabled
}
dynamicRepo, err := c.dynamicProfileRepository()
if err != nil {
return nil, err
}
if err := validateDynamicAgentProfile(req.Dynamic); err != nil {
return nil, err
}
profile := &models.AgentProfile{
ID: uuid.NewString(),
AgentID: agent.ID,
Name: strings.TrimSpace(req.Name),
AgentDisplayName: displayName,
Enabled: true,
CursorMCPAuthEnabled: cursorMCPAuthEnabled(req.CursorMCPAuthEnabled),
CLIFlags: []models.CLIFlag{},
EnvVars: []models.ProfileEnvVar{},
UserModified: true,
}
routes, err := c.validateDynamicCandidates(ctx, profile.ID, req.Dynamic)
if err != nil {
return nil, err
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
dynamic := &models.DynamicAgentProfile{ProfileID: profile.ID, Version: 1}
if err := dynamicRepo.CreateDynamicAgentProfile(ctx, dynamic, routes); err != nil {
if cleanupErr := c.repo.DeleteAgentProfile(ctx, profile.ID); cleanupErr != nil {
return nil, fmt.Errorf("%w; cleanup dynamic profile parent: %v", err, cleanupErr)
}
return nil, err
}
result := toProfileDTO(profile)
result.Dynamic, err = dynamicProfileDTO(dynamic, routes)
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Controller) dynamicProfileRepository() (store.DynamicProfileRepository, error) {
dynamicRepo, ok := c.repo.(store.DynamicProfileRepository)
if !ok {
return nil, fmt.Errorf("dynamic profile store is unavailable")
}
return dynamicRepo, nil
}
func (c *Controller) validateDynamicCandidates(
ctx context.Context,
profileID string,
dynamic *dto.DynamicAgentProfileDTO,
) ([]models.DynamicAgentRoute, error) {
routes, err := dynamicRoutesFromDTO(profileID, dynamic)
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(routes))
for _, route := range routes {
if route.ExecutionProfileID == profileID {
return nil, fmt.Errorf("%w: a profile cannot reference itself", ErrDynamicProfileCandidate)
}
if _, ok := seen[route.ExecutionProfileID]; ok {
return nil, fmt.Errorf("%w: duplicate execution profile %q", ErrDynamicProfileCandidate, route.ExecutionProfileID)
}
seen[route.ExecutionProfileID] = struct{}{}
candidate, err := c.repo.GetAgentProfileIncludingDeleted(ctx, route.ExecutionProfileID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: profile %q was not found", ErrDynamicProfileCandidate, route.ExecutionProfileID)
}
return nil, err
}
if candidate.DeletedAt != nil {
return nil, fmt.Errorf("%w: profile %q is deleted", ErrDynamicProfileCandidate, route.ExecutionProfileID)
}
candidateAgent, err := c.repo.GetAgent(ctx, candidate.AgentID)
if err != nil {
return nil, fmt.Errorf("%w: profile %q has no agent family", ErrDynamicProfileCandidate, route.ExecutionProfileID)
}
if candidate.AgentID == agents.DynamicAgentID || candidateAgent.Name == agents.DynamicAgentID {
return nil, fmt.Errorf("%w: dynamic profiles cannot be candidates", ErrDynamicProfileCandidate)
}
if candidate.WorkspaceID != "" || candidate.Role != "" {
return nil, fmt.Errorf("%w: rich Office profiles cannot be candidates", ErrDynamicProfileCandidate)
}
if candidate.AutoFallback {
return nil, fmt.Errorf("%w: AutoFallback profiles cannot be candidates", ErrDynamicProfileCandidate)
}
if _, ok := c.agentRegistry.GetInferenceAgent(candidateAgent.Name); !ok {
return nil, fmt.Errorf("%w: profile %q is not launchable", ErrDynamicProfileCandidate, route.ExecutionProfileID)
}
}
return routes, nil
}
// seedCLIFlags builds the default cli_flags list for a new profile from the
// agent's curated PermissionSettings() catalogue. Only entries that target a
// CLI flag are included; per-flag metadata (description, flag text, default
// enabled) is copied into the row so the profile is self-contained.
func seedCLIFlags(agent agents.Agent) []models.CLIFlag {
settings := agents.CatalogPermissionSettings(agent)
flags := make([]models.CLIFlag, 0, len(settings))
for key, s := range settings {
if !s.Supported || s.ApplyMethod != agents.PermissionApplyMethodCLIFlag || s.CLIFlag == "" {
continue
}
// dangerously_skip_permissions is wired to the profile's dedicated
// DangerouslySkipPermissions column; the passthrough launch path emits
// the flag via PermissionValues. Seeding it as a curated cli_flag too
// would surface a duplicate toggle in the UI and double-emit the flag.
if key == agents.PermissionKeyDangerouslySkipPermissions {
continue
}
flagText := s.CLIFlag
if s.CLIFlagValue != "" {
flagText = s.CLIFlag + " " + s.CLIFlagValue
}
flags = append(flags, models.CLIFlag{
Description: firstNonEmpty(s.Description, s.Label),
Flag: flagText,
Enabled: s.Default,
})
}
sort.Slice(flags, func(i, j int) bool { return flags[i].Flag < flags[j].Flag })
return flags
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
const (
dynamicRouteActionRetrySame = "retry_same"
dynamicRouteActionTryNext = "try_next"
dynamicRouteActionStop = "stop"
)
func validateDynamicAgentProfile(profile *dto.DynamicAgentProfileDTO) error {
if profile == nil || len(profile.Candidates) == 0 {
return ErrDynamicProfileCandidatesRequired
}
for position, candidate := range profile.Candidates {
if candidate.Position != position {
return fmt.Errorf("%w: candidate %d has position %d", ErrDynamicProfilePositions, position, candidate.Position)
}
if strings.TrimSpace(candidate.ExecutionProfileID) == "" {
return fmt.Errorf("%w: candidate %d has no execution profile", ErrDynamicProfileCandidate, position)
}
if err := normalizeDynamicCandidatePolicy(&profile.Candidates[position]); err != nil {
return err
}
}
return nil
}
func dynamicRoutesFromDTO(profileID string, profile *dto.DynamicAgentProfileDTO) ([]models.DynamicAgentRoute, error) {
if err := validateDynamicAgentProfile(profile); err != nil {
return nil, err
}
routes := make([]models.DynamicAgentRoute, 0, len(profile.Candidates))
for _, candidate := range profile.Candidates {
if candidate.Policies == nil {
return nil, fmt.Errorf("%w: candidate %d has no normalized policy", ErrDynamicProfileRule, candidate.Position)
}
policyJSON, err := json.Marshal(candidate.Policies)
if err != nil {
return nil, fmt.Errorf("marshal dynamic route policy: %w", err)
}
routes = append(routes, models.DynamicAgentRoute{
DynamicProfileID: profileID,
Position: candidate.Position,
ExecutionProfileID: strings.TrimSpace(candidate.ExecutionProfileID),
Enabled: candidate.Enabled,
RulesJSON: string(policyJSON),
})
}
return routes, nil
}
func dynamicProfileDTO(profile *models.DynamicAgentProfile, routes []models.DynamicAgentRoute) (*dto.DynamicAgentProfileDTO, error) {
if profile == nil {
return nil, nil
}
result := &dto.DynamicAgentProfileDTO{
Version: profile.Version,
Candidates: make([]dto.DynamicAgentCandidateDTO, 0, len(routes)),
}
for _, route := range routes {
policy, err := decodeDynamicPolicyDocument(route.RulesJSON, route.Position)
if err != nil {
return nil, err
}
result.Candidates = append(result.Candidates, dto.DynamicAgentCandidateDTO{
Position: route.Position,
ExecutionProfileID: route.ExecutionProfileID,
Enabled: route.Enabled,
Policies: &policy,
})
}
return result, nil
}
type UpdateProfileRequest struct {
ID string
Name *string
Model *string
FallbackModel *string
AutoFallback *bool
RequireExactModel *bool
Mode *string
ConfigOptions *map[string]string
AllowIndexing *bool
AutoApprove *bool
CLIPassthrough *bool
// Enabled replaces the value when non-nil. Nil means "leave unchanged".
Enabled *bool
// CLIFlags replaces the entire list when non-nil. Nil means "leave
// unchanged" — the UI always sends the full desired list on save.
CLIFlags *[]dto.CLIFlagDTO
// EnvVars replaces the entire list when non-nil.
EnvVars *[]dto.ProfileEnvVarDTO
// CommandPrefix replaces the value when non-nil. Nil means "leave
// unchanged" — the UI always sends the desired value on save.
CommandPrefix *string
// Provider* replace their value when non-nil. When any of the three is
// non-nil the provider configuration is re-validated and normalized.
ProviderKind *string
ProviderBaseURL *string
ProviderAPIKeySecretID *string
CursorMCPAuthEnabled *bool
Dynamic *dto.DynamicAgentProfileDTO
Force bool
}
func (req UpdateProfileRequest) touchesProvider() bool {
return req.ProviderKind != nil || req.ProviderBaseURL != nil || req.ProviderAPIKeySecretID != nil
}
func enabledOnlyUpdate(req UpdateProfileRequest) bool {
return req.Enabled != nil && req.Name == nil && req.Model == nil &&
req.FallbackModel == nil && req.AutoFallback == nil && req.RequireExactModel == nil && req.Mode == nil &&
req.ConfigOptions == nil && req.AllowIndexing == nil && req.AutoApprove == nil &&
req.CLIPassthrough == nil && req.CLIFlags == nil && req.EnvVars == nil &&
req.CommandPrefix == nil && !req.touchesProvider() && req.CursorMCPAuthEnabled == nil && req.Dynamic == nil
}
func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) {
profile, err := c.repo.GetAgentProfile(ctx, req.ID)
if err != nil {
return nil, ErrAgentProfileNotFound
}
isDynamic := profileKind(profile) == dynamicProfileKind
if isDynamic && !c.dynamicAgentRoutingEnabled {
return nil, ErrDynamicAgentRoutingDisabled
}
var (
dynamicRepo store.DynamicProfileRepository
dynamic *models.DynamicAgentProfile
dynamicRoutes []models.DynamicAgentRoute
)
if isDynamic && req.Dynamic != nil {
if req.Dynamic.Version <= 0 {
return nil, fmt.Errorf("dynamic profile version is required")
}
dynamicRoutes, err = c.validateDynamicCandidates(ctx, profile.ID, req.Dynamic)
if err != nil {
return nil, err
}
dynamicRepo, err = c.dynamicProfileRepository()
if err != nil {
return nil, err
}
dynamic = &models.DynamicAgentProfile{ProfileID: profile.ID}
}
if req.Name != nil {
profile.Name = *req.Name
}
if req.Model != nil {
profile.Model = *req.Model
}
if req.FallbackModel != nil {
profile.FallbackModel = strings.TrimSpace(*req.FallbackModel)
}
if req.AutoFallback != nil {
profile.AutoFallback = *req.AutoFallback
}
if req.RequireExactModel != nil {
profile.RequireExactModel = *req.RequireExactModel
}
if req.Mode != nil {
profile.Mode = *req.Mode
}
if req.ConfigOptions != nil {
profile.ConfigOptions = profileconfig.SanitizeConfigOptions(*req.ConfigOptions)
}
if req.AllowIndexing != nil {
profile.AllowIndexing = *req.AllowIndexing
}
if req.AutoApprove != nil {
profile.AutoApprove = *req.AutoApprove
}
if req.CLIPassthrough != nil {
profile.CLIPassthrough = *req.CLIPassthrough
}
if req.CursorMCPAuthEnabled != nil {
profile.CursorMCPAuthEnabled = *req.CursorMCPAuthEnabled
}
if err := validateRequireExactModelPolicy(profile.Model, profile.RequireExactModel, profile.CLIPassthrough, isDynamic); err != nil {
return nil, err
}
if req.Enabled != nil {
dynamicRefs, err := c.listDynamicProfileReferences(ctx, req.ID)
if err != nil {
return nil, err
}
if !*req.Enabled && !req.Force && len(dynamicRefs) > 0 {
return nil, &ErrProfileInUseDetail{DynamicProfiles: dynamicRefs}
}
if !*req.Enabled && !req.Force && c.utilityDeps != nil {
refs, err := c.utilityDeps.ListUtilityAgentsByAgentProfile(ctx, req.ID)
if err != nil {
return nil, fmt.Errorf("check utility agents using this profile: %w", err)
}
if len(refs) > 0 {
return nil, &ErrProfileInUseDetail{UtilityAgents: refs}
}
}
profile.Enabled = *req.Enabled
}
if enabledOnlyUpdate(req) {
profile.UserModified = true
updatedAt, err := c.repo.UpdateAgentProfileEnabled(ctx, profile.ID, profile.Enabled)
if err != nil {
return nil, err
}
profile.UpdatedAt = updatedAt
result := toProfileDTO(profile)
c.decorateProviderSupportByAgentID(ctx, &result)
return &result, nil
}
if req.CLIFlags != nil {
if err := validateCLIFlagDTOs(*req.CLIFlags); err != nil {
return nil, err
}
profile.CLIFlags = cliFlagsFromDTO(*req.CLIFlags)
}
if req.EnvVars != nil {
if err := validateProfileEnvVarDTOs(*req.EnvVars); err != nil {
return nil, err
}
if err := c.validateGlobalSecretRefs(ctx, *req.EnvVars); err != nil {
return nil, err
}
profile.EnvVars = envVarsFromDTO(*req.EnvVars)
}
if req.CommandPrefix != nil {
if err := validateCommandPrefix(*req.CommandPrefix); err != nil {
return nil, err
}
profile.CommandPrefix = strings.TrimSpace(*req.CommandPrefix)
}
if err := c.applyProviderConfigUpdate(ctx, req, profile); err != nil {
return nil, err
}
profile.UserModified = true
if dynamic != nil {
result, handled, atomicErr := c.updateDynamicProfileAtomically(
ctx, profile, dynamic, req.Dynamic.Version, dynamicRoutes,
)
if atomicErr != nil {
return nil, atomicErr
}
if handled {
return result, nil
}
}
if dynamicRepo != nil && dynamic != nil {
if err := dynamicRepo.UpdateDynamicAgentProfile(ctx, dynamic, req.Dynamic.Version, dynamicRoutes); err != nil {
return nil, err
}
}
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
return nil, err
}
result := toProfileDTO(profile)
c.decorateProviderSupportByAgentID(ctx, &result)
if dynamicRepo != nil && dynamic != nil {
result.Dynamic, err = dynamicProfileDTO(dynamic, dynamicRoutes)
if err != nil {
return nil, err
}
}
return &result, nil
}
// applyProviderConfigUpdate applies any provider-field overrides carried by req,
// then re-normalizes the provider configuration when those fields changed or
// when only the model changed on a profile that already routes through an
// OpenAI-compatible provider. That last case matters because a model id
// containing '/' otherwise slips back to the target CLI's built-in vendor
// provider without ever being rejected.
func (c *Controller) applyProviderConfigUpdate(
ctx context.Context, req UpdateProfileRequest, profile *models.AgentProfile,
) error {
if req.touchesProvider() {
if req.ProviderKind != nil {
profile.ProviderKind = *req.ProviderKind
}
if req.ProviderBaseURL != nil {
profile.ProviderBaseURL = *req.ProviderBaseURL
}
if req.ProviderAPIKeySecretID != nil {
profile.ProviderAPIKeySecretID = *req.ProviderAPIKeySecretID
}
}
modelChangedOnProviderProfile := req.Model != nil &&
strings.TrimSpace(profile.ProviderKind) == models.ProviderKindOpenAICompatible
passthroughEnabledOnProviderProfile := req.CLIPassthrough != nil && *req.CLIPassthrough &&
strings.TrimSpace(profile.ProviderKind) == models.ProviderKindOpenAICompatible
if !req.touchesProvider() && !modelChangedOnProviderProfile && !passthroughEnabledOnProviderProfile {
return nil
}
agentName := ""
if ag, agErr := c.repo.GetAgent(ctx, profile.AgentID); agErr == nil && ag != nil {
agentName = ag.Name
}
return c.normalizeProviderConfig(ctx, profile, agentName)
}
// validateRequireExactModelPolicy validates the merged profile policy before
// it reaches storage. Dynamic selectors and terminal passthrough profiles do
// not own a concrete ACP model, so they cannot promise exact selection.
func validateRequireExactModelPolicy(model string, requireExactModel, cliPassthrough, dynamic bool) error {
if !requireExactModel {
return nil
}
if dynamic || cliPassthrough {
return ErrRequireExactModelUnsupported
}
if strings.TrimSpace(model) == "" {
return ErrRequireExactModelNeedsModel
}
return nil
}
func (c *Controller) updateDynamicProfileAtomically(
ctx context.Context,
profile *models.AgentProfile,
dynamic *models.DynamicAgentProfile,
version int64,
routes []models.DynamicAgentRoute,
) (*dto.AgentProfileDTO, bool, error) {
atomicRepo, ok := c.repo.(store.AtomicDynamicProfileRepository)
if !ok {
return nil, false, nil
}
if err := atomicRepo.UpdateAgentProfileWithDynamic(ctx, profile, dynamic, version, routes); err != nil {
return nil, true, err
}
result := toProfileDTO(profile)
var err error
result.Dynamic, err = dynamicProfileDTO(dynamic, routes)
if err != nil {
return nil, true, err
}
return &result, true, nil
}
type DuplicateProfileRequest struct {
ID string
}
// DuplicateProfile creates an independent copy of an existing profile. The
// copy keeps every configuration field (model, mode, config options, CLI
// flags, env vars, launcher prefix, auto-approve flags, enabled state, MCP
// config) under a fresh row named "<source> Copy", so a user can start a
// variant from a working profile without re-entering it. Runtime state is
// intentionally not copied: the copy starts idle with no pause reason,
// last-run timestamp, or consecutive-failure count. No in-use checks apply —
// a brand-new row cannot be referenced by sessions, watchers, automations,
// or routing tiers yet.
//
// The copy is committed in one repository transaction (row + MCP config), so
// a failure leaves no partial profile and a disabled source never becomes
// briefly selectable.
//
// The source profile and MCP row are read up front, then the repository
// re-verifies those revisions inside the transaction; a concurrent writer
// between the reads and the insert aborts with a retryable error, so the
// copy always reflects one consistent snapshot of the source.
func (c *Controller) DuplicateProfile(ctx context.Context, req DuplicateProfileRequest) (*dto.AgentProfileDTO, error) {
source, err := c.repo.GetAgentProfile(ctx, req.ID)
if err != nil {
if isProfileNotFoundErr(err) {
return nil, ErrAgentProfileNotFound
}
return nil, err
}
// Office-scoped profiles are owned by the workspace-scoped office API
// surface and hidden from this instance-level settings surface (the UI
// filters them via filterGlobalProfiles). Refuse to duplicate them here so
// the endpoint cannot read or clone another workspace's configuration;
// 404 keeps the existence of office profiles hidden.
if source.WorkspaceID != "" {
return nil, ErrAgentProfileNotFound
}
if profileKind(source) == dynamicProfileKind {
return nil, ErrDynamicProfileDuplicationUnsupported
}
for attempt := 0; ; attempt++ {
// A source without an MCP row leaves the copy without one: the
// default-config semantics and boot EnsureDefaultMcpConfig cover
// MCP-supporting agents.
var sourceMcp *models.AgentProfileMcpConfig
sourceMcp, err = c.repo.GetAgentProfileMcpConfig(ctx, source.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if errors.Is(err, sql.ErrNoRows) {
sourceMcp = nil
}
clone := duplicateClone(source)
var mcpCopy *models.AgentProfileMcpConfig
if sourceMcp != nil {
mcpCopy = &models.AgentProfileMcpConfig{
Enabled: sourceMcp.Enabled,
Servers: cloneStringInterfaceMap(sourceMcp.Servers),
Meta: cloneStringInterfaceMap(sourceMcp.Meta),
}
}
err = c.repo.DuplicateAgentProfile(ctx, store.DuplicateAgentProfileInput{
Source: source,
SourceMcp: sourceMcp,
Profile: clone,
McpConfig: mcpCopy,
})
if err == nil {
result := toProfileDTO(clone)
c.decorateProviderSupportByAgentID(ctx, &result)
return &result, nil
}
// A source deleted between the snapshot read and the transaction is a
// deterministic 404, not a retryable race: the copy has nothing to
// reflect, so surface it immediately instead of re-reading the source
// up to maxDuplicateRetries times.
if errors.Is(err, store.ErrSourceProfileNotFound) {
return nil, ErrAgentProfileNotFound
}
if !isRetryableDuplicateErr(err) || attempt >= maxDuplicateRetries {
return nil, err
}
source, err = c.repo.GetAgentProfile(ctx, req.ID)
if err != nil {
if isProfileNotFoundErr(err) {
return nil, ErrAgentProfileNotFound
}
return nil, err
}
}
}
// maxDuplicateRetries bounds the number of re-attempts after a concurrent
// source change. One retry covers the common single-writer race; the cap
// prevents a hot loop under sustained concurrent edits.
const maxDuplicateRetries = 2
// isRetryableDuplicateErr reports whether a duplicate attempt failed because
// the source changed concurrently (revision mismatch or a WAL snapshot-isolation
// busy error on the write) rather than deterministically. A deleted source is
// intentionally absent: DuplicateProfile maps ErrSourceProfileNotFound to 404
// before consulting this set.
func isRetryableDuplicateErr(err error) bool {
return errors.Is(err, store.ErrProfileChanged) ||
strings.Contains(err.Error(), "database is locked") ||
strings.Contains(err.Error(), "database table is locked")
}
// duplicateClone builds the copy row from the source profile. Runtime state
// is intentionally not copied: the copy starts idle with no pause reason,
// last-run timestamp, or consecutive-failure count.
func duplicateClone(source *models.AgentProfile) *models.AgentProfile {
return &models.AgentProfile{
AgentID: source.AgentID,
Name: strings.TrimSpace(source.Name) + " Copy",
AgentDisplayName: source.AgentDisplayName,
Model: source.Model,
FallbackModel: strings.TrimSpace(source.FallbackModel),
AutoFallback: source.AutoFallback,
RequireExactModel: source.RequireExactModel,
Mode: source.Mode,
ConfigOptions: profileconfig.SanitizeConfigOptions(cloneStringMap(source.ConfigOptions)),
AllowIndexing: source.AllowIndexing,
AutoApprove: source.AutoApprove,
CLIPassthrough: source.CLIPassthrough,
CLIFlags: cloneCLIFlags(source.CLIFlags),
EnvVars: cloneEnvVars(source.EnvVars),
CommandPrefix: source.CommandPrefix,
ProviderKind: source.ProviderKind,
ProviderBaseURL: source.ProviderBaseURL,
ProviderAPIKeySecretID: source.ProviderAPIKeySecretID,
CursorMCPAuthEnabled: source.CursorMCPAuthEnabled,
UserModified: true,
Enabled: source.Enabled,
WorkspaceID: source.WorkspaceID,
Role: source.Role,
Icon: source.Icon,
ReportsTo: source.ReportsTo,
SkillIDs: source.SkillIDs,
DesiredSkills: source.DesiredSkills,
MaxConcurrentSessions: source.MaxConcurrentSessions,
CooldownSec: source.CooldownSec,
SkipIdleRuns: source.SkipIdleRuns,
FailureThreshold: cloneIntPtr(source.FailureThreshold),
ExecutorPreference: source.ExecutorPreference,
BudgetMonthlyCents: source.BudgetMonthlyCents,
Settings: source.Settings,
Permissions: source.Permissions,
DangerouslySkipPermissions: source.DangerouslySkipPermissions,
CustomPrompt: source.CustomPrompt,
}
}
// isProfileNotFoundErr reports whether err means "no live profile row with
// that ID". The sqlite store surfaces it as sql.ErrNoRows from GetAgentProfile
// and as an "agent profile not found" message from the update/delete paths;
// fakes and future stores may use either shape.
func isProfileNotFoundErr(err error) bool {
return errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "agent profile not found")
}
// cloneCLIFlags returns a copy of the profile's CLI flag list so the
// duplicated profile never shares slice memory with the source.
func cloneCLIFlags(in []models.CLIFlag) []models.CLIFlag {
out := make([]models.CLIFlag, len(in))
copy(out, in)
return out
}
// cloneEnvVars returns a copy of the profile's env-var list (secret
// references included) so the duplicated profile never shares slice memory
// with the source.
func cloneEnvVars(in []models.ProfileEnvVar) []models.ProfileEnvVar {
out := make([]models.ProfileEnvVar, len(in))
copy(out, in)
return out
}
// cloneStringMap returns a shallow copy of a string map, preserving nil so a
// source with no config options stays nil on the copy.
func cloneStringMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// cloneStringInterfaceMap returns a shallow copy of a string-keyed interface
// map (used for MCP servers/meta), preserving nil.
func cloneStringInterfaceMap(in map[string]interface{}) map[string]interface{} {
if in == nil {
return nil
}
out := make(map[string]interface{}, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// cloneIntPtr returns a copy of an int pointer so the duplicated profile never
// aliases the source's field, preserving nil.
func cloneIntPtr(in *int) *int {
if in == nil {
return nil
}
v := *in
return &v
}
func (c *Controller) validateGlobalSecretRefs(ctx context.Context, envVars []dto.ProfileEnvVarDTO) error {
if c.secretStore == nil {
return nil
}
for i, envVar := range envVars {
if strings.TrimSpace(envVar.SecretID) == "" {
continue
}
if err := secrets.ValidateGlobalReference(ctx, c.secretStore, envVar.SecretID); err != nil {
return fmt.Errorf("%w: env_vars[%d] secret must be global", ErrInvalidProfileEnvVars, i)
}
}
return nil
}
// validateCLIFlagDTOs rejects entries with an empty flag string or malformed
// shell tokens (unterminated quotes, trailing backslash). Empty descriptions
// are allowed (custom flags often don't have one). Tokenising here keeps the
// launch path's cliflags.Resolve error branch unreachable in practice: a
// single bad entry must not silently drop every other enabled flag at task
// start, which is what would happen if we let it slip through to the
// subprocess builder.
func validateCLIFlagDTOs(in []dto.CLIFlagDTO) error {
for i, f := range in {
if strings.TrimSpace(f.Flag) == "" {
return fmt.Errorf("cli_flags[%d].flag is required", i)
}
tokens, err := cliflags.Tokenise(f.Flag)
if err != nil {
return fmt.Errorf("cli_flags[%d]: %w", i, err)
}
// Reject entries where the primary token (the flag name itself) is
// empty — e.g. `""` or `''` passes TrimSpace but tokenises to a
// single blank argv element, which would reach the subprocess
// argv and likely confuse the agent. Secondary tokens can still
// be empty (`--empty ""` legitimately passes an empty value).
if len(tokens) == 0 || tokens[0] == "" {
return fmt.Errorf("cli_flags[%d].flag is required", i)
}
}
return nil
}
// validateCommandPrefix rejects a launcher prefix with malformed shell tokens
// (unterminated quotes, trailing backslash). An empty/whitespace-only prefix is
// valid — it means "run the agent command unwrapped". Tokenising here keeps the
// launch path's cliflags.Tokenise error branch unreachable in practice, so a
// bad prefix surfaces at save time rather than silently dropping at task start.
func validateCommandPrefix(prefix string) error {
if err := cliflags.ValidateCommandPrefix(prefix); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidCommandPrefix, err)
}
return nil
}
// providerSupported reports whether the profile's agent advertises a generic
// OpenAI-compatible provider (base URL + bearer key) that Kandev can inject.
func (c *Controller) providerSupported(agentName string) bool {
if c.agentRegistry == nil {
return false
}
ag, ok := c.agentRegistry.Get(agentName)
if !ok {
return false
}
return agents.OpenAICompatibleProviderSpecFor(ag) != nil
}
// normalizeProviderConfig validates and normalizes the provider fields on a
// profile in place. When the kind is not openai_compatible every provider field
// is cleared so a stale base URL or secret ref can never linger active. When it
// is openai_compatible the agent must support it, the base URL must be an
// absolute http(s) URL, and the model id must not contain '/' (the target CLI
// routes a slash-prefixed model to its built-in vendor provider). The secret
// ref, when set, must resolve to a global secret, and the base URL must then be
// https or a loopback host so the bearer key is never sent in cleartext.
func (c *Controller) normalizeProviderConfig(ctx context.Context, p *models.AgentProfile, agentName string) error {
if strings.TrimSpace(p.ProviderKind) != models.ProviderKindOpenAICompatible {
p.ProviderKind = models.ProviderKindNative
p.ProviderBaseURL = ""
p.ProviderAPIKeySecretID = ""
return nil
}
p.ProviderKind = models.ProviderKindOpenAICompatible
if !c.providerSupported(agentName) {
return fmt.Errorf("%w: agent %q does not support an OpenAI-compatible provider", ErrInvalidProviderConfig, agentName)
}
if p.CLIPassthrough {
return fmt.Errorf("%w: CLI passthrough cannot use an OpenAI-compatible provider", ErrInvalidProviderConfig)
}
p.ProviderBaseURL = strings.TrimSpace(p.ProviderBaseURL)
p.ProviderAPIKeySecretID = strings.TrimSpace(p.ProviderAPIKeySecretID)
validateBaseURL := acpprovider.ValidateBaseURL
if p.ProviderAPIKeySecretID != "" {
validateBaseURL = acpprovider.ValidateCredentialedBaseURL
}
if err := validateBaseURL(p.ProviderBaseURL); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidProviderConfig, err)
}
if strings.Contains(p.Model, "/") {
return fmt.Errorf("%w: model id %q must not contain '/'", ErrInvalidProviderConfig, p.Model)
}
if p.ProviderAPIKeySecretID != "" && c.secretStore != nil {
if err := secrets.ValidateGlobalReference(ctx, c.secretStore, p.ProviderAPIKeySecretID); err != nil {
return fmt.Errorf("%w: API key secret must be global", ErrInvalidProviderConfig)
}
}
return nil
}
func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) (*dto.AgentProfileDTO, error) {
profile, err := c.repo.GetAgentProfile(ctx, id)
if err != nil {
// The read path reports a missing (or already soft-deleted) row as
// sql.ErrNoRows, not as the "agent profile not found" message the
// update/delete paths use, so this has to go through the helper that
// knows both shapes or the sentinel is never returned.
if isProfileNotFoundErr(err) {
return nil, ErrAgentProfileNotFound
}
return nil, err
}
if profileKind(profile) == dynamicProfileKind && !c.dynamicAgentRoutingEnabled {
return nil, ErrDynamicAgentRoutingDisabled
}
if err := c.prepareProfileDeletion(ctx, id, force); err != nil {
return nil, err
}
// Automations are disabled BEFORE the row is deleted and a failure here
// aborts the whole delete. See disableReferencingAutomations for why this
// pass runs in the opposite order to the watcher pass below.
if err := c.disableReferencingAutomations(ctx, id); err != nil {
return nil, err
}
if err := c.repo.DeleteAgentProfile(ctx, id); err != nil {
if isProfileNotFoundErr(err) {
return nil, ErrAgentProfileNotFound
}
return nil, err
}
// Watchers, by contrast, are disabled only AFTER the row is gone, so a
// failed delete never strands watchers disabled against a still-live
// profile. They can afford that ordering because they have a genuine second
// chance: the dispatch coordinator's preflight re-resolves the profile on
// every watcher poll and disables the row itself when the profile has
// vanished. So a failure here is logged and ignored — the next poll fixes
// it. Automations have no such preflight, which is the whole reason they
// are handled above instead of here.
if force {
if c.utilityDeps != nil {
if err := c.utilityDeps.ClearUtilityAgentProfileBindings(ctx, id); err != nil {
return nil, fmt.Errorf("clear utility agents using this profile: %w", err)
}
}
c.disableReferencingWatchers(ctx, id, profile.Name)
}
result := toProfileDTO(profile)
return &result, nil
}
// prepareProfileDeletion blocks every routing-tier reference, then checks for
// active sessions and referencing watchers before cleaning up ephemeral tasks.
// Routing-tier references are hard blockers even when force=true because a
// deleted profile would orphan workspace tier mappings. When force is false,
// active sessions and watchers return *ErrProfileInUseDetail so the UI can
// render a confirmation dialog. force=true skips only those soft blockers.
// Neither branch disables anything: DeleteProfile disables referencing
// automations before the row goes away and referencing watchers after it does.
func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID string, force bool) error {
dynamicRefs, err := c.listDynamicProfileReferences(ctx, profileID)
if err != nil {
return err
}
if !force && len(dynamicRefs) > 0 {
return &ErrProfileInUseDetail{DynamicProfiles: dynamicRefs}
}
routingTierRefs, err := c.listRoutingTierReferences(ctx, profileID)
if err != nil {
return err
}
if len(routingTierRefs) > 0 {
return &ErrProfileInUseDetail{RoutingTiers: routingTierRefs}
}
var utilityRefs []UtilityAgentReference
if c.utilityDeps != nil {
refs, err := c.utilityDeps.ListUtilityAgentsByAgentProfile(ctx, profileID)
if err != nil {
return fmt.Errorf("check utility agents using this profile: %w", err)
}
utilityRefs = refs
}
if c.sessionChecker == nil {
if !force && len(utilityRefs) > 0 {
return &ErrProfileInUseDetail{UtilityAgents: utilityRefs, DynamicProfiles: dynamicRefs}
}
return nil
}
if !force {
activeTasks, err := c.sessionChecker.GetActiveTaskInfoByAgentProfile(ctx, profileID)
if err != nil {
return err
}
var watcherRefs []WatcherReference
if c.watcherDeps != nil {
refs, err := c.watcherDeps.ListWatchersByAgentProfile(ctx, profileID)
if err != nil {
c.logger.Warn("watcher deps lookup failed; proceeding without watcher info",
zap.String("profile_id", profileID), zap.Error(err))
} else {
watcherRefs = refs
}
}
var automationRefs []AutomationReference
if c.automationDeps != nil {
refs, err := c.automationDeps.ListEnabledAutomationsByAgentProfile(ctx, profileID)
if err != nil {
// Fails closed, unlike the watcher lookup above. A watcher this
// path misses is disabled anyway by the force pass afterwards;
// an automation is not, so a lookup that silently returned
// nothing would orphan exactly the references this check was
// added to catch. Refusing is recoverable — the user retries.
return fmt.Errorf("check automations using this profile: %w", err)
}
automationRefs = refs
}
// An enabled automation is a standing instruction to launch against this
// profile. Nothing is running, so it never appears in the active-session
// list, but its next firing would go looking for a profile that is gone —
// and a schedule fails quietly, hours later, with nobody watching.
if len(activeTasks) > 0 || len(watcherRefs) > 0 || len(automationRefs) > 0 || len(utilityRefs) > 0 {
return &ErrProfileInUseDetail{
ActiveSessions: activeTasks,
Watchers: watcherRefs,
Automations: automationRefs,
UtilityAgents: utilityRefs,
DynamicProfiles: dynamicRefs,
}
}
}
// Clean up ephemeral tasks (quick chat, config chat) using this profile.
// Done after the force check since these don't need user confirmation.
c.cleanupEphemeralTasks(ctx, profileID)
return nil
}
func (c *Controller) listRoutingTierReferences(ctx context.Context, profileID string) ([]RoutingTierReference, error) {
if c.routingTierDeps == nil {
return nil, nil
}
refs, err := c.routingTierDeps.ListRoutingTierReferencesByAgentProfile(ctx, profileID)
if err != nil {
return nil, err
}
return refs, nil
}
func (c *Controller) listDynamicProfileReferences(ctx context.Context, profileID string) ([]DynamicProfileReference, error) {
dynamicRepo, ok := c.repo.(store.DynamicProfileRepository)
if !ok {
return nil, nil
}
refs, err := dynamicRepo.ListDynamicProfileReferencesByExecutionProfile(ctx, profileID)
if err != nil {
return nil, fmt.Errorf("check dynamic profiles using this profile: %w", err)
}
out := make([]DynamicProfileReference, 0, len(refs))
for _, ref := range refs {
out = append(out, DynamicProfileReference{
ID: ref.ProfileID,
Name: ref.Name,
Deleted: ref.DeletedAt != nil,
})
}
return out, nil
}
// disableReferencingAutomations turns off every automation bound to the profile
// about to be deleted, and reports failure so DeleteProfile can abort.
//
// A force-delete is the user saying "yes, break these" — but an automation is
// not broken loudly. It is a standing instruction on a schedule, so left
// enabled it keeps firing into a profile that no longer exists, hours later,
// with nobody watching. Disabling is the honest outcome of the confirmation
// they just gave, and it is reversible: the automation is still there, and
// re-enabling it after picking a new profile is one toggle.
//
// This runs before the delete and is not best-effort, because the two failure
// directions cost wildly different amounts:
//
// - disable fails, delete aborted — automation still enabled, profile still
// there. Nothing is inconsistent, the error reaches the caller, and a retry
// costs one click.
// - disable fails, delete proceeds — automation enabled and bound to a row
// that is gone. Nothing ever notices. Automations have no equivalent of the
// watcher preflight to re-resolve the profile, so the binding is permanent
// and every future schedule fails quietly.
//
// The residual case is disable-succeeds-then-delete-fails, which leaves an
// automation disabled against a still-live profile. That is the direction we
// choose to lose in: it is visible on the automation page and one toggle to
// undo, where the other direction is silent and unrecoverable.
//
// Runs on both the force and non-force paths. On the non-force path
// prepareProfileDeletion has already refused the delete if any automation was
// enabled, so this is normally a no-op — except in the window where one is
// enabled between that check and this call, which it also closes.
func (c *Controller) disableReferencingAutomations(ctx context.Context, profileID string) error {
if c.automationDeps == nil {
return nil
}
disabled, err := c.automationDeps.DisableAutomationsByAgentProfile(ctx, profileID)
if err != nil {
return fmt.Errorf("disable automations using this profile: %w", err)
}
if len(disabled) > 0 {
c.logger.Info("disabled referencing automations before profile delete",
zap.String("profile_id", profileID), zap.Int("count", len(disabled)))
}
return nil
}
// disableReferencingWatchers stamps the deletion cause onto every watcher
// row that referenced this profile so the UI shows "disabled because the
// agent profile was deleted" the moment the request returns. Without this
// eager disable, watchers whose filter no longer matches anything after the
// profile is gone would stay enabled-but-orphaned indefinitely — the
// dispatch coordinator's preflight only runs when a new external event
// fires the watcher.
//
// Best-effort: a failure is logged and ignored so the delete still proceeds.
// The preflight remains as a safety net for reconciler-driven deletes that
// don't pass through this path.
func (c *Controller) disableReferencingWatchers(ctx context.Context, profileID, profileName string) {
if c.watcherDeps == nil {
return
}
cause := formatDeletedProfileCause(profileID, profileName)
disabled, err := c.watcherDeps.DisableWatchersByAgentProfile(ctx, profileID, cause)
if err != nil {
c.logger.Warn("failed to disable referencing watchers on force-delete",
zap.String("profile_id", profileID), zap.Error(err))
return
}
if len(disabled) > 0 {
c.logger.Info("disabled referencing watchers on profile force-delete",
zap.String("profile_id", profileID), zap.Int("count", len(disabled)))
}
}
// profileNameCauseMaxLen caps the rendered profile name in the deletion
// cause. Mirrors the orchestrator preflight's cap (80 runes); both strings
// land in the same settings-page watcher banner, and the name is user-typed
// with no DB-level length constraint.
const profileNameCauseMaxLen = 80
// formatDeletedProfileCause renders the human-readable string stamped onto a
// watcher's last_error when its profile is force-deleted. Includes the profile
// name (truncated) so the settings banner shows "Kilo Profile" rather than a
// bare UUID — matching the shape of the orchestrator preflight's cause.
func formatDeletedProfileCause(profileID, profileName string) string {
name := profileName
if runes := []rune(name); len(runes) > profileNameCauseMaxLen {
name = string(runes[:profileNameCauseMaxLen-1]) + "…"
}
if name != "" {
return fmt.Sprintf("agent profile %q (%s) was deleted", name, profileID)
}
return fmt.Sprintf("agent profile %s was deleted", profileID)
}
// cleanupEphemeralTasks removes ephemeral tasks (quick chat, config chat) associated with a profile.
func (c *Controller) cleanupEphemeralTasks(ctx context.Context, profileID string) {
if c.sessionChecker == nil {
return
}
deleted, err := c.sessionChecker.DeleteEphemeralTasksByAgentProfile(ctx, profileID)
if err != nil {
c.logger.Warn("failed to delete ephemeral tasks for profile",
zap.String("profile_id", profileID), zap.Error(err))
return
}
if deleted > 0 {
c.logger.Info("deleted ephemeral tasks for profile deletion",
zap.String("profile_id", profileID), zap.Int64("count", deleted))
}
}
func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO {
profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles))
for _, profile := range profiles {
profileDTOs = append(profileDTOs, toProfileDTO(profile))
}
result := dto.AgentDTO{
ID: agent.ID,
Name: agent.Name,
WorkspaceID: agent.WorkspaceID,
SupportsMCP: agent.SupportsMCP,
MCPConfigPath: agent.MCPConfigPath,
Profiles: profileDTOs,
CreatedAt: agent.CreatedAt,
UpdatedAt: agent.UpdatedAt,
}
if agent.TUIConfig != nil {
result.TUIConfig = &dto.TUIConfigDTO{
Command: agent.TUIConfig.Command,
DisplayName: agent.TUIConfig.DisplayName,
Model: agent.TUIConfig.Model,
Description: agent.TUIConfig.Description,
CommandArgs: agent.TUIConfig.CommandArgs,
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
MCPStrategy: agent.TUIConfig.MCPStrategy,
Protocol: agent.TUIConfig.Protocol,
DisableBracketedPaste: agent.TUIConfig.DisableBracketedPaste,
}
}
if c.agentRegistry != nil {
_, result.InferenceCapable = c.agentRegistry.GetInferenceAgent(agent.Name)
}
return result
}
// decorateAgentDTO attaches the dynamic document only after the parent
// profile list has been assembled. Concrete profiles stay on the existing
// lightweight path, while settings clients get the version and ordered
// candidates for dynamic profiles without reading secrets.
func (c *Controller) decorateAgentDTO(ctx context.Context, result *dto.AgentDTO) error {
if result == nil {
return nil
}
dynamicRepo, ok := c.repo.(store.DynamicProfileRepository)
if !ok {
for _, profile := range result.Profiles {
if profile.Kind == dynamicProfileKind {
return fmt.Errorf("dynamic profile store is unavailable")
}
}
return nil
}
for index := range result.Profiles {
if result.Profiles[index].Kind != dynamicProfileKind {
continue
}
config, routes, err := dynamicRepo.GetDynamicAgentProfile(ctx, result.Profiles[index].ID)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return err
}
result.Profiles[index].Dynamic, err = dynamicProfileDTO(config, routes)
if err != nil {
return err
}
}
return nil
}
func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
return dto.AgentProfileDTO{
ID: profile.ID,
AgentID: profile.AgentID,
Kind: profileKind(profile),
Name: profile.Name,
AgentDisplayName: profile.AgentDisplayName,
Model: profile.Model,
FallbackModel: profile.FallbackModel,
AutoFallback: profile.AutoFallback,
RequireExactModel: profile.RequireExactModel,
Mode: profile.Mode,
ConfigOptions: profileconfig.SanitizeConfigOptions(profile.ConfigOptions),
AllowIndexing: profile.AllowIndexing,
AutoApprove: profile.AutoApprove,
CLIFlags: cliFlagsToDTO(profile.CLIFlags),
EnvVars: envVarsToDTO(profile.EnvVars),
CLIPassthrough: profile.CLIPassthrough,
Enabled: profile.Enabled,
CommandPrefix: profile.CommandPrefix,
ProviderKind: profile.ProviderKind,
ProviderBaseURL: profile.ProviderBaseURL,
ProviderAPIKeySecretID: profile.ProviderAPIKeySecretID,
CursorMCPAuthEnabled: profile.CursorMCPAuthEnabled,
UserModified: profile.UserModified,
WorkspaceID: profile.WorkspaceID,
CreatedAt: profile.CreatedAt,
UpdatedAt: profile.UpdatedAt,
}
}
// decorateProviderSupport sets the computed ProviderSupported flag on a single
// profile DTO from the agent registry.
func (c *Controller) decorateProviderSupport(agentName string, d *dto.AgentProfileDTO) {
d.ProviderSupported = c.providerSupported(agentName)
}
// decorateProviderSupportByAgentID resolves the profile's agent family from the
// DTO's AgentID and sets the computed ProviderSupported flag. Mutation responses
// (update, duplicate) run through this: the frontend swaps its cached profile
// for the response, so an undecorated DTO hides the provider section until the
// agent list is fetched again.
func (c *Controller) decorateProviderSupportByAgentID(ctx context.Context, d *dto.AgentProfileDTO) {
ag, err := c.repo.GetAgent(ctx, d.AgentID)
if err != nil || ag == nil {
return
}
d.ProviderSupported = c.providerSupported(ag.Name)
}
func profileKind(profile *models.AgentProfile) string {
if profile != nil && profile.AgentID == agents.DynamicAgentID {
return dynamicProfileKind
}
return "concrete"
}
func cliFlagsToDTO(in []models.CLIFlag) []dto.CLIFlagDTO {
out := make([]dto.CLIFlagDTO, len(in))
for i, f := range in {
out[i] = dto.CLIFlagDTO{Description: f.Description, Flag: f.Flag, Enabled: f.Enabled}
}
return out
}
func cliFlagsFromDTO(in []dto.CLIFlagDTO) []models.CLIFlag {
out := make([]models.CLIFlag, len(in))
for i, f := range in {
out[i] = models.CLIFlag{Description: f.Description, Flag: f.Flag, Enabled: f.Enabled}
}
return out
}
func envVarsToDTO(in []models.ProfileEnvVar) []dto.ProfileEnvVarDTO {
if len(in) == 0 {
return nil
}
out := make([]dto.ProfileEnvVarDTO, len(in))
for i, ev := range in {
out[i] = dto.ProfileEnvVarDTO{Key: ev.Key, Value: ev.Value, SecretID: ev.SecretID}
}
return out
}
func envVarsFromDTO(in []dto.ProfileEnvVarDTO) []models.ProfileEnvVar {
out := make([]models.ProfileEnvVar, 0, len(in))
for _, ev := range in {
if strings.TrimSpace(ev.Key) == "" {
continue
}
out = append(out, models.ProfileEnvVar{
Key: strings.TrimSpace(ev.Key),
Value: ev.Value,
SecretID: ev.SecretID,
})
}
return out
}
const (
maxProfileEnvVars = 100
maxProfileEnvVarKeyLen = 256
maxProfileEnvVarValueLen = 8 * 1024
reservedProfileEnvVarKey = "TASK_DESCRIPTION"
reservedProfileEnvVarPrefix = "KANDEV_"
)
var posixEnvIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func validateProfileEnvVarDTOs(in []dto.ProfileEnvVarDTO) error {
if len(in) > maxProfileEnvVars {
return fmt.Errorf("%w: at most %d entries allowed", ErrInvalidProfileEnvVars, maxProfileEnvVars)
}
seen := make(map[string]int, len(in))
for i, ev := range in {
key := strings.TrimSpace(ev.Key)
if key != ev.Key {
return fmt.Errorf("%w: env_vars[%d].key must be a POSIX environment identifier", ErrInvalidProfileEnvVars, i)
}
if err := validateEnvVarKey(key, i, seen); err != nil {
return err
}
seen[key] = i
if err := validateEnvVarValue(ev, i); err != nil {
return err
}
}
return nil
}
func validateEnvVarKey(key string, i int, seen map[string]int) error {
if key == "" {
return fmt.Errorf("%w: env_vars[%d].key is required", ErrInvalidProfileEnvVars, i)
}
if len(key) > maxProfileEnvVarKeyLen {
return fmt.Errorf("%w: env_vars[%d].key exceeds %d characters", ErrInvalidProfileEnvVars, i, maxProfileEnvVarKeyLen)
}
if !posixEnvIdentifier.MatchString(key) {
return fmt.Errorf("%w: env_vars[%d].key must be a POSIX environment identifier", ErrInvalidProfileEnvVars, i)
}
if strings.HasPrefix(key, reservedProfileEnvVarPrefix) || key == reservedProfileEnvVarKey {
return fmt.Errorf("%w: env_vars[%d].key %q is reserved", ErrInvalidProfileEnvVars, i, key)
}
if first, exists := seen[key]; exists {
return fmt.Errorf("%w: env_vars[%d].key duplicates env_vars[%d].key", ErrInvalidProfileEnvVars, i, first)
}
return nil
}
func validateEnvVarValue(ev dto.ProfileEnvVarDTO, i int) error {
if ev.SecretID != "" && ev.Value != "" {
return fmt.Errorf("%w: env_vars[%d]: set value or secret_id, not both", ErrInvalidProfileEnvVars, i)
}
if ev.SecretID == "" && ev.Value == "" {
return fmt.Errorf("%w: env_vars[%d]: must set either value or secret_id", ErrInvalidProfileEnvVars, i)
}
if ev.Value != "" {
if len(ev.Value) > maxProfileEnvVarValueLen {
return fmt.Errorf("%w: env_vars[%d].value exceeds %d characters", ErrInvalidProfileEnvVars, i, maxProfileEnvVarValueLen)
}
if strings.Contains(ev.Value, "\x00") {
return fmt.Errorf("%w: env_vars[%d].value must not contain null bytes", ErrInvalidProfileEnvVars, i)
}
}
return nil
}