Files
kandev/apps/backend/internal/backendapp/settings_operations.go
T
2026-09-10 11:35:46 +01:00

1079 lines
35 KiB
Go

//revive:disable:file-length-limit // This adapter owns the complete shared settings boundary.
//nolint:goconst // Settings protocol keys stay readable at dispatch sites.
package backendapp
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/kandev/kandev/internal/agent/mcpconfig"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/authz"
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
"github.com/kandev/kandev/internal/settingscatalog"
usercontroller "github.com/kandev/kandev/internal/user/controller"
userdto "github.com/kandev/kandev/internal/user/dto"
userservice "github.com/kandev/kandev/internal/user/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
type settingsOperations struct {
registry *settingscatalog.Registry
profiles *agentsettingscontroller.Controller
user *usercontroller.Controller
userService *userservice.Service
deps settingsDomainDependencies
}
type settingsBroadcaster interface {
Broadcast(*ws.Message)
}
type settingsDomainDependencies struct {
broadcaster settingsBroadcaster
authEnabled func() bool
task settingsTaskService
workflow settingsWorkflowService
workflowCtrl settingsWorkflowController
prompts settingsPromptService
utility settingsUtilityService
editors settingsEditorService
notifications settingsNotificationController
runtimeFlags settingsRuntimeFlagsService
storage settingsStorageService
automation settingsAutomationService
agent settingsAgentService
jira settingsJiraService
linear settingsLinearService
sentry settingsSentryService
github settingsGitHubService
gitlab settingsGitLabService
azureDevOps settingsAzureDevOpsService
}
func newSettingsOperations(
registry *settingscatalog.Registry,
profiles *agentsettingscontroller.Controller,
userService *userservice.Service,
deps settingsDomainDependencies,
) mcphandlers.SettingsOperations {
var user *usercontroller.Controller
if userService != nil {
user = usercontroller.NewController(userService)
}
return &settingsOperations{registry: registry, profiles: profiles, user: user, userService: userService, deps: deps}
}
//nolint:cyclop,gocognit // Authorization combines caller mode, field authority, and workspace ownership rules.
func (s *settingsOperations) authorizeSettingsMutation(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) error {
identity, ok := authn.IdentityFromContext(ctx)
if !ok {
if s.deps.authEnabled != nil && !s.deps.authEnabled() {
return nil
}
return fmt.Errorf("settings mutation requires a caller identity")
}
if identity.Synthetic {
return nil
}
if isIntegrationResource(target.ResourceType) {
if err := s.requireIntegrationDependency(target.ResourceType); err != nil {
return err
}
} else {
switch target.ResourceType {
case "agent", "agent_profile", "agent_profile_mcp", "user_settings":
default:
if err := s.requireDomainDependency(target.ResourceType); err != nil {
return err
}
}
}
for key := range changes {
field, ok := settingsField(s.registry, target.ResourceType, key)
if !ok {
return fmt.Errorf("unknown setting %q", key)
}
if field.Authority == "user.self" {
continue
}
scope, ok := settingsAuthorityScope(field.Authority)
if !ok {
return fmt.Errorf("setting %q has no authorization binding", field.Key)
}
if scope == authz.ScopeOrgConfigManage || scope == authz.ScopeOrgSettingsManage {
subject := authz.Subject{
UserID: identity.UserID, OrgID: identity.OrgID,
OrgRole: authz.NormalizeOrgRole(string(identity.Role)),
}
if !authz.SubjectOrgScopes(subject).Has(scope) {
return fmt.Errorf("caller lacks %s", scope)
}
continue
}
if s.deps.task == nil {
return fmt.Errorf("workspace authorization is unavailable")
}
workspaceID, err := s.settingsWorkspaceID(ctx, target)
if err != nil {
return err
}
if err := s.deps.task.AuthorizeWorkspaceScope(ctx, workspaceID, scope); err != nil {
return err
}
}
return nil
}
func settingsAuthorityScope(authority string) (authz.Scope, bool) {
switch authority {
case "org.config.manage", "execution.manage", "prompt.manage", "utility.manage", "editor.manage", "notification.manage":
return authz.ScopeOrgConfigManage, true
case "org.settings.manage":
return authz.ScopeOrgSettingsManage, true
case "workspace.manage":
return authz.ScopeWorkspaceManage, true
case "repository.manage":
return authz.ScopeRepositoryManage, true
case "task.manage", "task.write":
return authz.ScopeTaskWrite, true
default:
return "", false
}
}
//nolint:cyclop,gocognit,funlen // Resource-to-workspace resolution must remain adjacent to the authorization boundary.
func (s *settingsOperations) settingsWorkspaceID(ctx context.Context, target settingscatalog.ResourceTarget) (string, error) {
if target.WorkspaceID != nil && strings.TrimSpace(*target.WorkspaceID) != "" {
return strings.TrimSpace(*target.WorkspaceID), nil
}
if target.ResourceID == nil || strings.TrimSpace(*target.ResourceID) == "" {
return "", fmt.Errorf("resource_id is required for workspace authorization")
}
id := strings.TrimSpace(*target.ResourceID)
switch target.ResourceType {
case "workspace":
return id, nil
case "workflow":
item, err := s.deps.task.GetWorkflow(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("workflow not found")
}
return item.WorkspaceID, nil
case "workflow_step":
response, err := s.deps.workflowCtrl.GetStep(ctx, id)
if err != nil {
return "", err
}
if response == nil || response.Step == nil {
return "", fmt.Errorf("workflow step not found")
}
workflow, err := s.deps.task.GetWorkflow(ctx, response.Step.WorkflowID)
if err != nil {
return "", err
}
if workflow == nil {
return "", fmt.Errorf("workflow not found")
}
return workflow.WorkspaceID, nil
case "repository":
item, err := s.deps.task.GetRepository(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("repository not found")
}
return item.WorkspaceID, nil
case "repository_script":
item, err := s.deps.task.GetRepositoryScript(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("repository script not found")
}
repository, err := s.deps.task.GetRepository(ctx, item.RepositoryID)
if err != nil {
return "", err
}
if repository == nil {
return "", fmt.Errorf("repository not found")
}
return repository.WorkspaceID, nil
case "repository_set":
item, err := s.deps.task.GetRepositorySet(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("repository set not found")
}
return item.WorkspaceID, nil
case "task":
item, err := s.deps.task.GetTask(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("task not found")
}
return item.WorkspaceID, nil
case "automation":
item, err := s.deps.automation.GetAutomation(ctx, id)
if err != nil {
return "", err
}
if item == nil {
return "", fmt.Errorf("automation not found")
}
return item.WorkspaceID, nil
case "automation_trigger":
trigger, err := s.deps.automation.GetTrigger(ctx, id)
if err != nil {
return "", err
}
if trigger == nil {
return "", fmt.Errorf("automation trigger not found")
}
automation, err := s.deps.automation.GetAutomation(ctx, trigger.AutomationID)
if err != nil {
return "", err
}
if automation == nil {
return "", fmt.Errorf("automation not found")
}
return automation.WorkspaceID, nil
case "agent":
agent, err := s.profiles.GetAgent(ctx, id)
if err != nil {
return "", err
}
return agentWorkspaceID(*agent), nil
case "agent_profile":
profile, err := s.findProfile(ctx, target)
if err != nil {
return "", err
}
return profile.WorkspaceID, nil
default:
return "", fmt.Errorf("resource type %q does not have a workspace scope", target.ResourceType)
}
}
func (s *settingsOperations) ReadSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) {
if isIntegrationResource(target.ResourceType) {
return s.readIntegrationSettings(ctx, target, keys)
}
switch target.ResourceType {
case "agent":
return s.readAgentSettings(ctx, target, keys)
case "agent_profile":
profile, err := s.findProfile(ctx, target)
if err != nil {
return nil, err
}
values := projectJSON(profile, s.registry, target.ResourceType, keys)
redactSettingsValues(values, s.registry, target.ResourceType)
return map[string]any{
"target": target, "values": values, "source": "profile", "updated_at": profile.UpdatedAt,
}, nil
case "agent_profile_mcp":
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("profile MCP settings are unavailable")
}
config, err := s.profiles.GetAgentProfileMcpConfig(ctx, *target.ResourceID)
if err != nil {
return nil, err
}
values := projectJSON(config, s.registry, target.ResourceType, keys)
redactSettingsValues(values, s.registry, target.ResourceType)
return map[string]any{"target": target, "values": values, "source": "profile_mcp"}, nil
case "user_settings":
if s.user == nil {
return nil, fmt.Errorf("user settings are unavailable")
}
response, err := s.user.GetUserSettings(ctx)
if err != nil {
return nil, err
}
values := projectJSON(response.Settings, s.registry, target.ResourceType, keys)
return map[string]any{"target": target, "values": values, "source": "user_settings", "updated_at": response.Settings.UpdatedAt}, nil
default:
return s.readDomainSettings(ctx, target, keys)
}
}
func (s *settingsOperations) DescribeSetting(ctx context.Context, request mcphandlers.SettingDescriptionRequest) (mcphandlers.SettingDescription, error) {
field, domain, ok := s.registry.Find(request.Key)
if !ok {
return mcphandlers.SettingDescription{}, fmt.Errorf("setting %q was not found", request.Key)
}
field, err := s.targetDescriptionField(field, domain, request.Target)
if err != nil {
return mcphandlers.SettingDescription{}, err
}
if len(request.Context) == 0 {
return mcphandlers.SettingDescription{}, nil
}
if err := validateDescriptionContext(request.Key, field, request.Target, request.Context); err != nil {
return mcphandlers.SettingDescription{}, err
}
return s.describeProfileOptions(ctx, request)
}
func (s *settingsOperations) targetDescriptionField(
field settingscatalog.FieldDescriptor,
domain settingscatalog.DomainDescriptor,
target *settingscatalog.ResourceTarget,
) (settingscatalog.FieldDescriptor, error) {
if target == nil {
return field, nil
}
if err := s.registry.ValidateTarget(*target); err != nil {
return settingscatalog.FieldDescriptor{}, err
}
if target.ResourceType != domain.ResourceType {
return settingscatalog.FieldDescriptor{}, fmt.Errorf("target resource_type does not match setting")
}
return settingscatalog.FieldForTarget(field, target), nil
}
func validateDescriptionContext(
key string,
field settingscatalog.FieldDescriptor,
target *settingscatalog.ResourceTarget,
contextValues map[string]any,
) error {
if field.Key != "agent_profile.config_options" || target == nil || target.ResourceID == nil {
return fmt.Errorf("setting context is not supported for %q", key)
}
for contextKey := range contextValues {
switch contextKey {
case "model", "mode", "config_options", "refresh":
default:
return fmt.Errorf("unsupported setting context %q", contextKey)
}
}
return nil
}
func (s *settingsOperations) describeProfileOptions(
ctx context.Context,
request mcphandlers.SettingDescriptionRequest,
) (mcphandlers.SettingDescription, error) {
profile, err := s.findProfile(ctx, *request.Target)
if err != nil {
return mcphandlers.SettingDescription{}, err
}
agentName, err := s.profileAgentName(ctx, profile.ID)
if err != nil {
return mcphandlers.SettingDescription{}, err
}
refresh, err := descriptionBoolContext(request.Context, "refresh")
if err != nil {
return mcphandlers.SettingDescription{}, err
}
caps, err := s.profiles.FetchDynamicModels(ctx, agentName, refresh)
if err != nil {
return mcphandlers.SettingDescription{}, err
}
choices := make([]map[string]any, 0, len(caps.Models)+len(caps.Modes))
for _, model := range caps.Models {
choices = append(choices, map[string]any{"kind": "model", "id": model.ID, "label": model.Name, "description": model.Description})
}
for _, mode := range caps.Modes {
choices = append(choices, map[string]any{"kind": "mode", "id": mode.ID, "label": mode.Name, "description": mode.Description})
}
model, err := descriptionModelContext(request.Context, profile.Model)
if err != nil {
return mcphandlers.SettingDescription{}, err
}
configOptions := []agentsettingsdto.ConfigOptionDTO{}
if model != "" {
mode, err := descriptionStringContext(request.Context, "mode")
if err != nil {
return mcphandlers.SettingDescription{}, err
}
selectedOptions, err := descriptionConfigOptionsContext(request.Context, "config_options")
if err != nil {
return mcphandlers.SettingDescription{}, err
}
resolution, err := s.profiles.ResolveAgentModelConfig(ctx, agentName, agentsettingsdto.ResolveAgentModelConfigRequest{
Model: model, Mode: mode, ConfigOptions: selectedOptions, Refresh: refresh,
})
if err != nil {
return mcphandlers.SettingDescription{}, err
}
configOptions = resolution.ConfigOptions
}
for _, option := range configOptions {
item := map[string]any{
"kind": "config_option", "id": option.ID, "label": option.Name,
"type": option.Type, "current_value": option.CurrentValue, "description": option.Description,
}
if len(option.Options) > 0 {
values := make([]map[string]any, 0, len(option.Options))
for _, value := range option.Options {
values = append(values, map[string]any{"value": value.Value, "label": value.Name, "description": value.Description})
}
item["options"] = values
}
choices = append(choices, item)
}
return mcphandlers.SettingDescription{Choices: choices}, nil
}
func descriptionModelContext(contextValues map[string]any, fallback string) (string, error) {
value, ok := contextValues["model"]
if !ok {
return fallback, nil
}
model, ok := value.(string)
if !ok {
return "", fmt.Errorf("setting context %q must be a string", "model")
}
return model, nil
}
func descriptionStringContext(contextValues map[string]any, key string) (string, error) {
value, ok := contextValues[key]
if !ok {
return "", nil
}
parsed, ok := value.(string)
if !ok {
return "", fmt.Errorf("setting context %q must be a string", key)
}
return parsed, nil
}
func descriptionConfigOptionsContext(contextValues map[string]any, key string) (map[string]string, error) {
value, ok := contextValues[key]
if !ok {
return nil, nil
}
entries, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("setting context %q must be an object", key)
}
result := make(map[string]string, len(entries))
for entryKey, entryValue := range entries {
parsed, ok := entryValue.(string)
if !ok {
return nil, fmt.Errorf("setting context %q values must be strings", key)
}
result[entryKey] = parsed
}
return result, nil
}
func descriptionBoolContext(contextValues map[string]any, key string) (bool, error) {
value, ok := contextValues[key]
if !ok {
return false, nil
}
parsed, ok := value.(bool)
if !ok {
return false, fmt.Errorf("setting context %q must be boolean", key)
}
return parsed, nil
}
func (s *settingsOperations) profileAgentName(ctx context.Context, profileID string) (string, error) {
response, err := s.profiles.ListAgents(ctx)
if err != nil {
return "", err
}
for _, agent := range response.Agents {
for _, profile := range agent.Profiles {
if profile.ID == profileID {
return agent.Name, nil
}
}
}
return "", fmt.Errorf("agent profile %q is not visible", profileID)
}
func (s *settingsOperations) UpdateSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage, options map[string]json.RawMessage) (any, error) {
if err := s.authorizeSettingsMutation(ctx, target, changes); err != nil {
return nil, err
}
if isIntegrationResource(target.ResourceType) {
return s.updateIntegrationSettings(ctx, target, changes, options)
}
switch target.ResourceType {
case "agent":
return s.updateAgentSettings(ctx, target, changes)
case "agent_profile":
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("profile settings are unavailable")
}
request, err := decodeProfileUpdate(*target.ResourceID, changes, options)
if err != nil {
return nil, err
}
profile, err := s.profiles.UpdateProfile(ctx, agentsettingscontroller.UpdateProfileRequestFromDTO(request))
if err != nil {
return nil, err
}
return map[string]any{"target": target, "accepted_fields": changePaths(changes), "profile": s.sanitizeSettingsValue(target.ResourceType, profile), "source": "profile", "updated_at": profile.UpdatedAt}, nil
case "agent_profile_mcp":
return s.updateProfileMCP(ctx, target, changes)
case "user_settings":
if s.user == nil {
return nil, fmt.Errorf("user settings are unavailable")
}
request, err := decodeUserSettingsUpdate(changes)
if err != nil {
return nil, err
}
response, err := s.user.UpdateUserSettings(ctx, request)
if err != nil {
return nil, err
}
return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, response.Settings), "source": "user_settings", "updated_at": response.Settings.UpdatedAt}, nil
default:
return s.updateDomainSettings(ctx, target, changes, options)
}
}
//nolint:cyclop,gocognit // This public boundary selects personal, profile, integration, and domain resource lookup.
func (s *settingsOperations) ListSettingsResources(ctx context.Context, resourceType string, workspaceID *string, query string, limit int, cursor string) (any, error) {
if isIntegrationResource(resourceType) {
return s.listIntegrationSettingsResources(ctx, resourceType, workspaceID, query, limit, cursor)
}
if resourceType == "user_settings" {
return map[string]any{"resources": []any{map[string]any{"target": settingscatalog.ResourceTarget{ResourceType: resourceType}, "label": "Current user"}}, "total": 1}, nil
}
if resourceType == "agent" && s.profiles != nil {
response, err := s.profiles.ListAgents(ctx)
if err != nil {
return nil, err
}
resources := make([]map[string]any, 0, len(response.Agents))
needle := strings.ToLower(strings.TrimSpace(query))
for _, agent := range response.Agents {
if workspaceID != nil && *workspaceID != "" && (agent.WorkspaceID == nil || *agent.WorkspaceID != *workspaceID) {
continue
}
if needle != "" && !strings.Contains(strings.ToLower(agent.ID), needle) && !strings.Contains(strings.ToLower(agent.Name), needle) {
continue
}
resources = append(resources, map[string]any{
"target": settingscatalog.ResourceTarget{ResourceType: resourceType, ResourceID: settingsStringPointer(agent.ID), WorkspaceID: nonEmptyPointer(agentWorkspaceID(agent))},
"label": agent.Name,
})
}
sort.Slice(resources, func(i, j int) bool { return resources[i]["label"].(string) < resources[j]["label"].(string) })
return pageSettingsResources(resources, limit, cursor), nil
}
if resourceType != "agent_profile" || s.profiles == nil {
return s.listDomainSettingsResources(ctx, resourceType, workspaceID, query, limit, cursor)
}
response, err := s.profiles.ListAgents(ctx)
if err != nil {
return nil, err
}
resources := make([]map[string]any, 0)
needle := strings.ToLower(strings.TrimSpace(query))
for _, agent := range response.Agents {
for _, profile := range agent.Profiles {
if workspaceID != nil && *workspaceID != "" && profile.WorkspaceID != *workspaceID {
continue
}
if needle != "" && !strings.Contains(strings.ToLower(profile.ID), needle) && !strings.Contains(strings.ToLower(profile.Name), needle) {
continue
}
resources = append(resources, map[string]any{
"target": settingscatalog.ResourceTarget{ResourceType: resourceType, ResourceID: settingsStringPointer(profile.ID), WorkspaceID: nonEmptyPointer(profile.WorkspaceID)},
"label": profile.Name, "agent_id": profile.AgentID, "agent_name": agent.Name,
})
}
}
sort.Slice(resources, func(i, j int) bool {
return resources[i]["label"].(string) < resources[j]["label"].(string)
})
start := parseSettingsCursor(cursor)
if start > len(resources) {
start = len(resources)
}
end := start + limit
if end > len(resources) {
end = len(resources)
}
result := map[string]any{"resources": resources[start:end], "total": len(resources)}
if end < len(resources) {
result["next_cursor"] = fmt.Sprintf("%d", end)
}
return result, nil
}
func agentWorkspaceID(agent agentsettingsdto.AgentDTO) string {
if agent.WorkspaceID == nil {
return ""
}
return *agent.WorkspaceID
}
func (s *settingsOperations) readAgentSettings(ctx context.Context, target settingscatalog.ResourceTarget, keys []string) (any, error) {
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("agent settings are unavailable")
}
agent, err := s.profiles.GetAgent(ctx, *target.ResourceID)
if err != nil {
return nil, err
}
if target.WorkspaceID != nil && (agent.WorkspaceID == nil || *agent.WorkspaceID != *target.WorkspaceID) {
return nil, fmt.Errorf("agent is not visible in the requested workspace")
}
values := projectJSON(agent, s.registry, target.ResourceType, keys)
redactSettingsValues(values, s.registry, target.ResourceType)
redactAgentProfileCollections(values)
return map[string]any{"target": target, "values": values, "source": "agent", "updated_at": agent.UpdatedAt}, nil
}
func (s *settingsOperations) updateAgentSettings(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) {
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("agent settings are unavailable")
}
normalized, err := normalizeChangeMap(changes, "agent")
if err != nil {
return nil, err
}
request := agentsettingscontroller.UpdateAgentRequest{ID: *target.ResourceID}
for key, raw := range normalized {
switch key {
case "supports_mcp":
var value bool
if err := json.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("supports_mcp must be boolean")
}
request.SupportsMCP = &value
case "mcp_config_path":
request.MCPConfigPathSet = true
var value *string
if string(raw) != "null" {
var decoded string
if err := json.Unmarshal(raw, &decoded); err != nil {
return nil, fmt.Errorf("mcp_config_path must be string or null")
}
value = &decoded
}
request.MCPConfigPath = value
default:
return nil, fmt.Errorf("setting %q is not writable", key)
}
}
updated, err := s.profiles.UpdateAgent(ctx, request)
if err != nil {
return nil, err
}
return map[string]any{"target": target, "accepted_fields": changePaths(changes), "agent": s.sanitizeSettingsValue(target.ResourceType, updated), "source": "agent", "updated_at": updated.UpdatedAt}, nil
}
func (s *settingsOperations) findProfile(ctx context.Context, target settingscatalog.ResourceTarget) (*agentsettingsdto.AgentProfileDTO, error) {
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("profile settings are unavailable")
}
response, err := s.profiles.ListAgents(ctx)
if err != nil {
return nil, err
}
for _, agent := range response.Agents {
for index := range agent.Profiles {
profile := &agent.Profiles[index]
if profile.ID == *target.ResourceID {
if target.WorkspaceID != nil && profile.WorkspaceID != *target.WorkspaceID {
return nil, fmt.Errorf("profile is not visible in the requested workspace")
}
return profile, nil
}
}
}
return nil, fmt.Errorf("agent profile not found")
}
func decodeProfileUpdate(profileID string, changes map[string]json.RawMessage, options map[string]json.RawMessage) (agentsettingsdto.ProfileUpdateRequest, error) {
normalized, err := normalizeChangeMap(changes, "agent_profile")
if err != nil {
return agentsettingsdto.ProfileUpdateRequest{}, err
}
normalized["id"] = json.RawMessage(strconvQuote(profileID))
requestJSON, err := json.Marshal(normalized)
if err != nil {
return agentsettingsdto.ProfileUpdateRequest{}, err
}
var request agentsettingsdto.ProfileUpdateRequest
if err := json.Unmarshal(requestJSON, &request); err != nil {
return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("invalid profile settings: %w", err)
}
if force, exists := options["force"]; exists {
if err := json.Unmarshal(force, &request.Force); err != nil {
return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("force must be boolean")
}
}
for key := range options {
if key != "force" {
return agentsettingsdto.ProfileUpdateRequest{}, fmt.Errorf("unsupported profile option %q", key)
}
}
return request, nil
}
func decodeUserSettingsUpdate(changes map[string]json.RawMessage) (userdto.UpdateUserSettingsRequest, error) {
normalized, err := normalizeChangeMap(changes, "user_settings")
if err != nil {
return userdto.UpdateUserSettingsRequest{}, err
}
if raw, ok := normalized["sidebar_task_colors"]; ok {
if string(raw) == "null" {
return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("sidebar_task_colors must be an object")
}
var colors map[string]*string
if err := json.Unmarshal(raw, &colors); err != nil || colors == nil {
return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("sidebar_task_colors must be an object")
}
patch, marshalErr := json.Marshal(map[string]any{"colors": colors})
if marshalErr != nil {
return userdto.UpdateUserSettingsRequest{}, marshalErr
}
delete(normalized, "sidebar_task_colors")
normalized["sidebar_task_color_patch"] = patch
}
payload, err := json.Marshal(normalized)
if err != nil {
return userdto.UpdateUserSettingsRequest{}, err
}
var request userdto.UpdateUserSettingsRequest
if err := json.Unmarshal(payload, &request); err != nil {
return userdto.UpdateUserSettingsRequest{}, fmt.Errorf("invalid user settings: %w", err)
}
return request, nil
}
func normalizeChangeMap(changes map[string]json.RawMessage, resourceType string) (map[string]json.RawMessage, error) {
normalized := make(map[string]json.RawMessage, len(changes))
for key, value := range changes {
prefix := resourceType + "."
key = strings.TrimPrefix(key, prefix)
if _, exists := normalized[key]; exists {
return nil, fmt.Errorf("setting %q was supplied more than once", key)
}
normalized[key] = value
}
return normalized, nil
}
func (s *settingsOperations) updateProfileMCP(ctx context.Context, target settingscatalog.ResourceTarget, changes map[string]json.RawMessage) (any, error) {
if s.profiles == nil || target.ResourceID == nil {
return nil, fmt.Errorf("profile MCP settings are unavailable")
}
profile, err := s.findProfile(ctx, target)
if err != nil {
return nil, err
}
normalized, err := normalizeChangeMap(changes, "agent_profile_mcp")
if err != nil {
return nil, err
}
request := agentsettingscontroller.UpdateAgentProfileMcpConfigPatchRequest{}
for key, value := range normalized {
switch key {
case "enabled":
var enabled bool
if string(value) == "null" || json.Unmarshal(value, &enabled) != nil {
return nil, fmt.Errorf("enabled must be boolean")
}
request.Enabled = &enabled
case "servers":
if string(value) == "null" {
return nil, fmt.Errorf("servers must be an object")
}
var servers map[string]mcpconfig.ServerDef
if err := json.Unmarshal(value, &servers); err != nil || servers == nil {
return nil, fmt.Errorf("servers must be an object")
}
request.Servers = &servers
default:
return nil, fmt.Errorf("setting %q is not writable", key)
}
}
updated, err := s.profiles.UpdateAgentProfileMcpConfigPatch(ctx, *target.ResourceID, request)
if err != nil {
return nil, err
}
s.broadcastProfileMCPConfigUpdated(*target.ResourceID, profile.WorkspaceID)
return map[string]any{"target": target, "accepted_fields": changePaths(changes), "settings": s.sanitizeSettingsValue(target.ResourceType, updated), "source": "profile_mcp"}, nil
}
func (s *settingsOperations) broadcastProfileMCPConfigUpdated(profileID, workspaceID string) {
if s.deps.broadcaster == nil || profileID == "" {
return
}
var scopedWorkspaceID any
if workspaceID != "" {
scopedWorkspaceID = workspaceID
}
notification, err := ws.NewNotification(ws.ActionAgentProfileMCPConfigUpdated, map[string]any{
"profile_id": profileID,
"workspace_id": scopedWorkspaceID,
})
if err != nil {
return
}
if workspaceID != "" {
workspaceHub, ok := s.deps.broadcaster.(interface {
BroadcastToWorkspaceOrDrop(string, *ws.Message)
})
if ok {
workspaceHub.BroadcastToWorkspaceOrDrop(workspaceID, notification)
}
return
}
//ws:global profile MCP updates without workspace ownership are global.
s.deps.broadcaster.Broadcast(notification)
}
//nolint:nestif // The repository-set projection needs its nested stored-resource fallback.
func projectJSON(value any, registry *settingscatalog.Registry, resourceType string, keys []string) map[string]any {
payload := map[string]any{}
encoded, err := json.Marshal(value)
if err != nil {
return payload
}
_ = json.Unmarshal(encoded, &payload)
result := make(map[string]any, len(keys))
for _, key := range keys {
field, ok := settingsField(registry, resourceType, key)
if !ok {
continue
}
if current, exists := lookupJSONPath(payload, field.FieldPath); exists {
result[field.FieldPath] = current
continue
}
if resourceType == "repository_set" && field.FieldPath == "repository_ids" {
if items, ok := payload["repositories"].([]any); ok {
ids := make([]any, 0, len(items))
for _, item := range items {
if object, ok := item.(map[string]any); ok {
if id, exists := object["repository_id"]; exists {
ids = append(ids, id)
}
}
}
result[field.FieldPath] = ids
}
}
}
return result
}
func lookupJSONPath(payload map[string]any, path string) (any, bool) {
parts := strings.Split(path, ".")
var current any = payload
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[part]
if !ok {
return nil, false
}
}
return current, true
}
func settingsField(registry *settingscatalog.Registry, resourceType, key string) (settingscatalog.FieldDescriptor, bool) {
domain, ok := registry.Domain(resourceType)
if !ok {
return settingscatalog.FieldDescriptor{}, false
}
for _, field := range domain.Fields {
if field.Key == key || field.FieldPath == key {
return field, true
}
}
return settingscatalog.FieldDescriptor{}, false
}
func (s *settingsOperations) sanitizeSettingsValue(resourceType string, value any) any {
if isIntegrationResource(resourceType) {
paths := make([]string, 0)
if domain, ok := s.registry.Domain(resourceType); ok {
for _, field := range domain.Fields {
paths = append(paths, field.FieldPath)
}
}
values := projectJSON(value, s.registry, resourceType, paths)
redactSettingsValues(values, s.registry, resourceType)
return values
}
payload, err := json.Marshal(value)
if err != nil {
return map[string]any{"redacted": true}
}
var decoded any
if err := json.Unmarshal(payload, &decoded); err != nil {
return map[string]any{"redacted": true}
}
if object, ok := decoded.(map[string]any); ok {
redactSettingsValues(object, s.registry, resourceType)
if resourceType == "agent" {
redactAgentProfileCollections(object)
}
return object
}
return decoded
}
func redactAgentProfileCollections(values map[string]any) {
profiles, ok := values["profiles"].([]any)
if !ok {
return
}
for _, rawProfile := range profiles {
profile, ok := rawProfile.(map[string]any)
if !ok {
continue
}
if envVars, exists := profile["env_vars"]; exists {
profile["env_vars"] = redactSensitiveValue(envVars)
}
}
}
func redactSettingsValues(values map[string]any, registry *settingscatalog.Registry, resourceType string) {
domain, ok := registry.Domain(resourceType)
if !ok {
return
}
for _, field := range domain.Fields {
if !field.Sensitive {
continue
}
if current, exists := lookupJSONPath(values, field.FieldPath); exists {
_ = setJSONPath(values, field.FieldPath, redactSensitiveValue(current))
}
}
}
func setJSONPath(payload map[string]any, path string, value any) error {
parts := strings.Split(path, ".")
if len(parts) == 0 || parts[0] == "" {
return fmt.Errorf("invalid JSON path %q", path)
}
current := payload
for _, part := range parts[:len(parts)-1] {
nested, ok := current[part].(map[string]any)
if !ok {
return fmt.Errorf("invalid JSON path %q", path)
}
current = nested
}
current[parts[len(parts)-1]] = value
return nil
}
func redactSensitiveValue(value any) any {
switch typed := value.(type) {
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = redactSensitiveValue(item)
}
return result
case map[string]any:
result := make(map[string]any, len(typed)+1)
redacted := false
for key, item := range typed {
if sensitiveJSONKey(key) {
result[key] = "[redacted]"
redacted = true
continue
}
switch item.(type) {
case map[string]any, []any:
result[key] = redactSensitiveValue(item)
default:
if safeJSONKey(key) {
result[key] = item
} else {
result[key] = "[redacted]"
redacted = true
}
}
}
if redacted {
result["redacted"] = true
}
return result
default:
return "[redacted]"
}
}
func sensitiveJSONKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), " ", "_"))
if normalized == "secret_id" || normalized == "secret_ref" {
return false
}
for _, token := range []string{"value", "secret", "token", "password", "credential", "authorization", "api_key", "private_key", "headers", "env", "env_vars"} {
if normalized == token || strings.Contains(normalized, token) {
return true
}
}
return false
}
func safeJSONKey(key string) bool {
switch strings.ToLower(key) {
case "id", "key", "name", "type", "kind", "scope", "ref", "secret_id", "description", "command", "args", "enabled", "required":
return true
default:
return false
}
}
func changePaths(changes map[string]json.RawMessage) []string {
paths := make([]string, 0, len(changes))
for key := range changes {
paths = append(paths, key)
}
sort.Strings(paths)
return paths
}
func parseSettingsCursor(cursor string) int {
var value int
if _, err := fmt.Sscanf(cursor, "%d", &value); err != nil || value < 0 {
return 0
}
return value
}
func settingsStringPointer(value string) *string { return &value }
func nonEmptyPointer(value string) *string {
if value == "" {
return nil
}
return &value
}
func strconvQuote(value string) []byte {
encoded, _ := json.Marshal(value)
return encoded
}