feat: security hardening, tests, doctor command, CI update, CHANGELOG
All checks were successful
CI / build (push) Successful in 2m37s
All checks were successful
CI / build (push) Successful in 2m37s
- Add AES-256-GCM encryption for API keys (internal/secret) - Add dangerous command detection in terminal - Add muyue doctor command for system health checks - Add scanner TTL cache, orchestrator history mutex, shared HTTP client - Deduplicate MCP config generation, refactor skills YAML parser - Add XDG-compliant config dir with legacy migration - Add cleanup on all TUI quit paths - Add 8 test files (config, workflow, skills, orchestrator, version, platform, scanner, secret) - Update CI to actions/setup-go@v5 - Add CHANGELOG.md, update README and Makefile 🤖 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/muyue/muyue/internal/secret"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -57,14 +58,30 @@ type MuyueConfig struct {
|
||||
}
|
||||
|
||||
func ConfigDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(home, ".muyue")
|
||||
dir := filepath.Join(configDir, "muyue")
|
||||
|
||||
legacyDir := filepath.Join(homeDir(), ".muyue")
|
||||
if _, err := os.Stat(legacyDir); err == nil {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
os.Rename(legacyDir, dir)
|
||||
}
|
||||
}
|
||||
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
func homeDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func ConfigPath() (string, error) {
|
||||
dir, err := ConfigDir()
|
||||
if err != nil {
|
||||
@@ -98,6 +115,17 @@ func Load() (*MuyueConfig, error) {
|
||||
return nil, fmt.Errorf("parsing config: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt API keys
|
||||
for i := range cfg.AI.Providers {
|
||||
if cfg.AI.Providers[i].APIKey != "" {
|
||||
decrypted, err := secret.Decrypt(cfg.AI.Providers[i].APIKey)
|
||||
if err != nil {
|
||||
decrypted = cfg.AI.Providers[i].APIKey
|
||||
}
|
||||
cfg.AI.Providers[i].APIKey = decrypted
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -111,8 +139,21 @@ func Save(cfg *MuyueConfig) error {
|
||||
return fmt.Errorf("creating config dir: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt API keys before saving
|
||||
saveCfg := *cfg
|
||||
saveCfg.AI.Providers = make([]AIProvider, len(cfg.AI.Providers))
|
||||
for i, p := range cfg.AI.Providers {
|
||||
saveCfg.AI.Providers[i] = p
|
||||
if p.APIKey != "" && !secret.IsEncrypted(p.APIKey) {
|
||||
enc, err := secret.Encrypt(p.APIKey)
|
||||
if err == nil {
|
||||
saveCfg.AI.Providers[i].APIKey = enc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data, err := yaml.Marshal(cfg)
|
||||
data, err := yaml.Marshal(&saveCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
154
internal/config/config_test.go
Normal file
154
internal/config/config_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Version != "0.1.0" {
|
||||
t.Errorf("Expected version 0.1.0, got %s", cfg.Version)
|
||||
}
|
||||
if cfg.Profile.Pseudo != "muyue" {
|
||||
t.Errorf("Expected pseudo muyue, got %s", cfg.Profile.Pseudo)
|
||||
}
|
||||
if len(cfg.AI.Providers) == 0 {
|
||||
t.Error("Expected at least one AI provider")
|
||||
}
|
||||
found := false
|
||||
for _, p := range cfg.AI.Providers {
|
||||
if p.Name == "minimax" && p.Active {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected minimax to be active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
origConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfig)
|
||||
|
||||
cfg := Default()
|
||||
cfg.Profile.Name = "Test User"
|
||||
cfg.Profile.Email = "test@example.com"
|
||||
cfg.Profile.Pseudo = "tester"
|
||||
cfg.Profile.Languages = []string{"go", "python"}
|
||||
cfg.AI.Providers[0].APIKey = "test-key-123"
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
if !Exists() {
|
||||
t.Error("Exists should return true after Save")
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Profile.Name != "Test User" {
|
||||
t.Errorf("Expected name Test User, got %s", loaded.Profile.Name)
|
||||
}
|
||||
if loaded.Profile.Pseudo != "tester" {
|
||||
t.Errorf("Expected pseudo tester, got %s", loaded.Profile.Pseudo)
|
||||
}
|
||||
if loaded.Profile.Email != "test@example.com" {
|
||||
t.Errorf("Expected email test@example.com, got %s", loaded.Profile.Email)
|
||||
}
|
||||
if len(loaded.Profile.Languages) != 2 {
|
||||
t.Errorf("Expected 2 languages, got %d", len(loaded.Profile.Languages))
|
||||
}
|
||||
if loaded.AI.Providers[0].APIKey != "test-key-123" {
|
||||
t.Error("API key should be decrypted on load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistsFalse(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
origConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfig)
|
||||
|
||||
if Exists() {
|
||||
t.Error("Exists should return false for non-existent config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfig)
|
||||
|
||||
dir, err := ConfigDir()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigDir failed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(tmpDir, ".config", "muyue")
|
||||
if dir != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfig)
|
||||
|
||||
path, err := ConfigPath()
|
||||
if err != nil {
|
||||
t.Fatalf("ConfigPath failed: %v", err)
|
||||
}
|
||||
expected := filepath.Join(tmpDir, ".config", "muyue", "config.yaml")
|
||||
if path != expected {
|
||||
t.Errorf("Expected %s, got %s", expected, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundtripEmptyFields(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
origHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", origHome)
|
||||
|
||||
origConfig := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, ".config"))
|
||||
defer os.Setenv("XDG_CONFIG_HOME", origConfig)
|
||||
|
||||
cfg := Default()
|
||||
cfg.Profile.Name = ""
|
||||
cfg.AI.Providers[0].APIKey = ""
|
||||
|
||||
if err := Save(cfg); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded.Profile.Name != "" {
|
||||
t.Errorf("Expected empty name, got %s", loaded.Profile.Name)
|
||||
}
|
||||
if loaded.AI.Providers[0].APIKey != "" {
|
||||
t.Error("Expected empty API key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user