diff --git a/internal/config/ai.go b/internal/config/ai.go index 4f7f669da..b2444050f 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -64,7 +64,7 @@ type AIConfig struct { // AI Infrastructure Control settings // These control whether AI can take actions on infrastructure (start/stop VMs, containers, etc.) - ControlLevel string `json:"control_level,omitempty"` // "read_only", "suggest", "controlled", "autonomous" + ControlLevel string `json:"control_level,omitempty"` // "read_only", "controlled", "autonomous" ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs or names that AI cannot control } @@ -81,8 +81,6 @@ const ( const ( // ControlLevelReadOnly - AI can only query infrastructure, no control tools available ControlLevelReadOnly = "read_only" - // ControlLevelSuggest - AI suggests commands, user must copy/paste to execute - ControlLevelSuggest = "suggest" // ControlLevelControlled - AI can execute with per-command approval ControlLevelControlled = "controlled" // ControlLevelAutonomous - AI executes without approval (requires Pro license) @@ -506,7 +504,14 @@ func (c *AIConfig) GetControlLevel() string { } return ControlLevelReadOnly } - return c.ControlLevel + switch c.ControlLevel { + case ControlLevelReadOnly, ControlLevelControlled, ControlLevelAutonomous: + return c.ControlLevel + case "suggest": + return ControlLevelControlled + default: + return ControlLevelReadOnly + } } // IsControlEnabled returns true if AI has any control capability beyond read-only @@ -523,7 +528,7 @@ func (c *AIConfig) IsAutonomous() bool { // IsValidControlLevel checks if a control level string is valid func IsValidControlLevel(level string) bool { switch level { - case ControlLevelReadOnly, ControlLevelSuggest, ControlLevelControlled, ControlLevelAutonomous: + case ControlLevelReadOnly, ControlLevelControlled, ControlLevelAutonomous: return true default: return false diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 75c5ed949..08e53fdd5 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -1661,10 +1661,10 @@ func (c *ConfigPersistence) SaveAIConfig(settings AIConfig) error { // LoadAIConfig retrieves the persisted AI settings. It returns default config when no configuration exists yet. func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { c.mu.RLock() - defer c.mu.RUnlock() data, err := c.fs.ReadFile(c.aiFile) if err != nil { + c.mu.RUnlock() if os.IsNotExist(err) { // Return default config if file doesn't exist return NewDefaultAIConfig(), nil @@ -1675,6 +1675,7 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { if c.crypto != nil { decrypted, err := c.crypto.Decrypt(data) if err != nil { + c.mu.RUnlock() return nil, err } data = decrypted @@ -1683,6 +1684,7 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { // Start with defaults so new fields get proper values settings := NewDefaultAIConfig() if err := json.Unmarshal(data, settings); err != nil { + c.mu.RUnlock() return nil, err } @@ -1692,6 +1694,22 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { settings.PatrolIntervalMinutes = 15 } + migratedControlLevel := false + if settings.ControlLevel == "suggest" { + settings.ControlLevel = ControlLevelControlled + migratedControlLevel = true + } + + c.mu.RUnlock() + + if migratedControlLevel { + if err := c.SaveAIConfig(*settings); err != nil { + log.Warn().Err(err).Msg("Failed to save migrated AI control level") + } else { + log.Info().Str("control_level", settings.ControlLevel).Msg("Migrated AI control level") + } + } + log.Info().Str("file", c.aiFile).Bool("enabled", settings.Enabled).Bool("patrol_enabled", settings.PatrolEnabled).Bool("alert_triggered_analysis", settings.AlertTriggeredAnalysis).Msg("AI configuration loaded") return settings, nil } diff --git a/internal/config/persistence_ai_test.go b/internal/config/persistence_ai_test.go index 717f36f0c..3928820e0 100644 --- a/internal/config/persistence_ai_test.go +++ b/internal/config/persistence_ai_test.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "path/filepath" "testing" @@ -115,6 +116,31 @@ func TestPersistence_AIConfig(t *testing.T) { assert.Error(t, err) } +func TestPersistence_AIConfig_MigratesSuggestControlLevel(t *testing.T) { + tempDir := t.TempDir() + p := NewConfigPersistence(tempDir) + + cfg := NewDefaultAIConfig() + cfg.ControlLevel = "suggest" + require.NoError(t, p.SaveAIConfig(*cfg)) + + loaded, err := p.LoadAIConfig() + require.NoError(t, err) + assert.Equal(t, ControlLevelControlled, loaded.ControlLevel) + + updatedRaw, err := os.ReadFile(filepath.Join(tempDir, "ai.enc")) + require.NoError(t, err) + if p.crypto != nil { + decoded, err := p.crypto.Decrypt(updatedRaw) + require.NoError(t, err) + updatedRaw = decoded + } + + var saved AIConfig + require.NoError(t, json.Unmarshal(updatedRaw, &saved)) + assert.Equal(t, ControlLevelControlled, saved.ControlLevel) +} + func TestPersistence_PatrolRunHistory(t *testing.T) { tempDir := t.TempDir() p := NewConfigPersistence(tempDir)