Files
MyGoNavi/internal/ai/service/config_store.go
Syngnat c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00

270 lines
7.8 KiB
Go

package aiservice
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/dailysecret"
"GoNavi-Wails/internal/secretstore"
)
const (
aiConfigSchemaVersion = 2
aiConfigFileName = "ai_config.json"
)
type aiConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Providers []ai.ProviderConfig `json:"providers"`
ActiveProvider string `json:"activeProvider"`
SafetyLevel string `json:"safetyLevel"`
ContextLevel string `json:"contextLevel"`
}
type ProviderConfigStoreSnapshot struct {
Providers []ai.ProviderConfig
ActiveProvider string
SafetyLevel ai.SQLPermissionLevel
ContextLevel ai.ContextLevel
}
type ProviderConfigStoreInspection struct {
Snapshot ProviderConfigStoreSnapshot
ProvidersNeedingMigration []string
}
type ProviderConfigStore struct {
configDir string
secretStore secretstore.SecretStore
dailySecrets *dailysecret.Store
}
func NewProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
if strings.TrimSpace(configDir) == "" {
configDir = resolveConfigDir()
}
if store == nil {
store = secretstore.NewUnavailableStore("secret store unavailable")
}
return &ProviderConfigStore{
configDir: configDir,
secretStore: store,
dailySecrets: dailysecret.NewStore(configDir),
}
}
func newProviderConfigStore(configDir string, store secretstore.SecretStore) *ProviderConfigStore {
return NewProviderConfigStore(configDir, store)
}
func (s *ProviderConfigStore) configPath() string {
return filepath.Join(s.configDir, aiConfigFileName)
}
func (s *ProviderConfigStore) Load() (ProviderConfigStoreSnapshot, error) {
cfg, snapshot, err := s.readStoredSnapshot()
if err != nil {
return snapshot, err
}
shouldRewrite := cfg.SchemaVersion != aiConfigSchemaVersion
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig, rewritten, loadErr := s.loadStoredProviderConfig(providerConfig)
if loadErr != nil {
return snapshot, fmt.Errorf("加载 AI Provider secret 失败(provider=%s): %w", providerConfig.ID, loadErr)
}
if rewritten {
shouldRewrite = true
}
providers = append(providers, runtimeConfig)
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
if shouldRewrite {
if err := s.Save(snapshot); err != nil {
return snapshot, fmt.Errorf("重写 AI 配置失败: %w", err)
}
}
return snapshot, nil
}
func (s *ProviderConfigStore) LoadRuntime() (ProviderConfigStoreSnapshot, error) {
return s.Load()
}
func (s *ProviderConfigStore) Inspect() (ProviderConfigStoreInspection, error) {
_, snapshot, err := s.readStoredSnapshot()
inspection := ProviderConfigStoreInspection{
Snapshot: snapshot,
ProvidersNeedingMigration: []string{},
}
if err != nil {
return inspection, err
}
for _, providerConfig := range snapshot.Providers {
if providerNeedsMigration(providerConfig) {
inspection.ProvidersNeedingMigration = append(inspection.ProvidersNeedingMigration, providerConfig.ID)
}
}
return inspection, nil
}
func (s *ProviderConfigStore) Save(snapshot ProviderConfigStoreSnapshot) error {
providers := make([]ai.ProviderConfig, 0, len(snapshot.Providers))
for _, providerConfig := range snapshot.Providers {
runtimeConfig := normalizeProviderConfig(providerConfig)
meta, bundle := splitProviderSecrets(runtimeConfig)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
if err != nil {
return fmt.Errorf("保存 Provider secret 失败: %w", err)
}
meta = storedMeta
} else if meta.HasSecret {
resolved, _, err := s.loadStoredProviderConfig(meta)
if err != nil {
return fmt.Errorf("保存 Provider secret 失败: %w", err)
}
meta = providerMetadataView(resolved)
}
providers = append(providers, providerMetadataView(meta))
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
cfg := aiConfig{
SchemaVersion: aiConfigSchemaVersion,
Providers: providers,
ActiveProvider: snapshot.ActiveProvider,
SafetyLevel: string(snapshot.SafetyLevel),
ContextLevel: string(snapshot.ContextLevel),
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("序列化 AI 配置失败: %w", err)
}
if err := os.MkdirAll(s.configDir, 0o755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
if err := os.WriteFile(s.configPath(), data, 0o644); err != nil {
return fmt.Errorf("写入 AI 配置失败: %w", err)
}
return nil
}
func (s *ProviderConfigStore) readStoredSnapshot() (aiConfig, ProviderConfigStoreSnapshot, error) {
snapshot := ProviderConfigStoreSnapshot{
Providers: []ai.ProviderConfig{},
SafetyLevel: ai.PermissionReadOnly,
ContextLevel: ai.ContextSchemaOnly,
}
data, err := os.ReadFile(s.configPath())
if err != nil {
if os.IsNotExist(err) {
return aiConfig{}, snapshot, nil
}
return aiConfig{}, snapshot, fmt.Errorf("读取 AI 配置失败: %w", err)
}
var cfg aiConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return aiConfig{}, snapshot, fmt.Errorf("加载 AI 配置失败: %w", err)
}
snapshot.ActiveProvider = cfg.ActiveProvider
switch ai.SQLPermissionLevel(cfg.SafetyLevel) {
case ai.PermissionReadOnly, ai.PermissionReadWrite, ai.PermissionFull:
snapshot.SafetyLevel = ai.SQLPermissionLevel(cfg.SafetyLevel)
}
switch ai.ContextLevel(cfg.ContextLevel) {
case ai.ContextSchemaOnly, ai.ContextWithSamples, ai.ContextWithResults:
snapshot.ContextLevel = ai.ContextLevel(cfg.ContextLevel)
}
providers := make([]ai.ProviderConfig, 0, len(cfg.Providers))
for _, providerConfig := range cfg.Providers {
providers = append(providers, normalizeProviderConfig(providerConfig))
}
if providers == nil {
providers = []ai.ProviderConfig{}
}
snapshot.Providers = providers
return cfg, snapshot, nil
}
func (s *ProviderConfigStore) loadStoredProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, bool, error) {
meta, bundle := splitProviderSecrets(config)
if bundle.hasAny() {
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, bundle)
if err != nil {
return meta, false, err
}
return mergeProviderSecrets(storedMeta, bundle), true, nil
}
if !meta.HasSecret {
return meta, false, nil
}
if stored, ok, err := s.dailySecrets.GetAIProvider(meta.ID); err != nil {
return meta, false, err
} else if ok {
rewritten := strings.TrimSpace(meta.SecretRef) != ""
meta.SecretRef = ""
return mergeProviderSecrets(meta, fromDailyProviderBundle(stored)), rewritten, nil
}
if !shouldReadLegacyProviderSecretStore() {
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
if strings.TrimSpace(meta.SecretRef) != "" {
resolved, err := resolveProviderConfigSecretsFromStore(s.secretStore, meta)
if err != nil {
if os.IsNotExist(err) || secretstore.IsUnavailable(err) {
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
return meta, false, err
}
_, migratedBundle := splitProviderSecrets(resolved)
storedMeta, err := persistProviderSecretBundle(s.dailySecrets, meta, migratedBundle)
if err != nil {
return meta, false, err
}
return mergeProviderSecrets(storedMeta, migratedBundle), true, nil
}
meta.HasSecret = false
meta.SecretRef = ""
return meta, true, nil
}
func (s *ProviderConfigStore) loadRuntimeProviderConfig(config ai.ProviderConfig) (ai.ProviderConfig, error) {
runtimeConfig, _, err := s.loadStoredProviderConfig(config)
return runtimeConfig, err
}
func providerNeedsMigration(config ai.ProviderConfig) bool {
_, bundle := splitProviderSecrets(normalizeProviderConfig(config))
return bundle.hasAny() || strings.TrimSpace(config.SecretRef) != ""
}