дȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 д Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
@@ -57,7 +57,7 @@ func (manager *Manager) currentConfig() Config {
|
||||
return DefaultConfig()
|
||||
}
|
||||
if current := manager.current.Load(); current != nil {
|
||||
return *current
|
||||
return cloneConfig(*current)
|
||||
}
|
||||
return DefaultConfig()
|
||||
}
|
||||
@@ -75,7 +75,12 @@ func (manager *Manager) Save(ctx context.Context, cfg Config) (Config, error) {
|
||||
return Config{}, fmt.Errorf("config manager is not initialized")
|
||||
}
|
||||
manager.saveMu.Lock()
|
||||
defer manager.saveMu.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
normalized, err := manager.store.Save(ctx, cfg)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -86,6 +91,8 @@ func (manager *Manager) Save(ctx context.Context, cfg Config) (Config, error) {
|
||||
manager.lastReload = time.Now()
|
||||
manager.reloadError = ""
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
locked = false
|
||||
manager.notify(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
@@ -104,13 +111,17 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
|
||||
normalizedValue := strings.TrimSpace(value)
|
||||
// 在 saveMu 保护下完成 read-modify-write,避免与并发 Save 互相覆盖。
|
||||
manager.saveMu.Lock()
|
||||
defer manager.saveMu.Unlock()
|
||||
locked := true
|
||||
defer func() {
|
||||
if locked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
current := manager.currentConfig()
|
||||
if strings.TrimSpace(current.LastAgentModelHash) == normalizedValue {
|
||||
return nil
|
||||
}
|
||||
current.LastAgentModelHash = normalizedValue
|
||||
normalized, err := manager.store.Save(ctx, current)
|
||||
normalized, err := manager.store.updateLastAgentModelHash(normalizedValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,6 +131,8 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
|
||||
manager.lastReload = time.Now()
|
||||
manager.reloadError = ""
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
locked = false
|
||||
manager.notify(normalized)
|
||||
return nil
|
||||
}
|
||||
@@ -164,7 +177,9 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
|
||||
adapters := make([]legacyruntime.ModelAdapterConfig, 0, len(cfg.ModelAdapters))
|
||||
for _, item := range cfg.ModelAdapters {
|
||||
adapters = append(adapters, legacyruntime.ModelAdapterConfig{
|
||||
ID: item.ID,
|
||||
ID: item.ID,
|
||||
CustomHeadersEnabled: item.CustomHeadersEnabled, CustomHeadersJSON: item.CustomHeadersJSON,
|
||||
AnthropicExtraParamsEnabled: item.AnthropicExtraParamsEnabled, AnthropicExtraParamsJSON: item.AnthropicExtraParamsJSON,
|
||||
DisplayName: item.DisplayName,
|
||||
Type: item.Type,
|
||||
BaseURL: item.BaseURL,
|
||||
@@ -204,7 +219,7 @@ func (manager *Manager) RouteMode(hasUpstreamURL bool) string {
|
||||
}
|
||||
|
||||
func (manager *Manager) setCurrent(cfg Config) {
|
||||
next := cfg
|
||||
next := cloneConfig(cfg)
|
||||
manager.current.Store(&next)
|
||||
}
|
||||
|
||||
@@ -215,6 +230,13 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
manager.saveMu.Lock()
|
||||
reloadSaveLocked := true
|
||||
defer func() {
|
||||
if reloadSaveLocked {
|
||||
manager.saveMu.Unlock()
|
||||
}
|
||||
}()
|
||||
now := time.Now()
|
||||
manager.reloadMu.Lock()
|
||||
if !manager.lastReload.IsZero() && now.Sub(manager.lastReload) < configHotReloadMinInterval {
|
||||
@@ -241,6 +263,8 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
|
||||
manager.reloadError = ""
|
||||
manager.setCurrent(cfg)
|
||||
manager.reloadMu.Unlock()
|
||||
manager.saveMu.Unlock()
|
||||
reloadSaveLocked = false
|
||||
manager.notify(cfg)
|
||||
}
|
||||
|
||||
@@ -250,7 +274,7 @@ func (manager *Manager) notify(cfg Config) {
|
||||
manager.listenersMu.RUnlock()
|
||||
for _, listener := range listeners {
|
||||
if listener != nil {
|
||||
listener(cfg)
|
||||
listener(cloneConfig(cfg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ const (
|
||||
// 默认值越过服务方上限会直接返回 400(exceeds the configured maximum output length)。
|
||||
defaultChannelMaxTokens = 278_000
|
||||
defaultChannelAnthropicMaxTokens = 131_072
|
||||
defaultChannelThinkingBudget = 4_096
|
||||
defaultChannelAnthropicEffort = "xhigh"
|
||||
defaultChannelThinkingBudget = 4_096
|
||||
defaultChannelAnthropicEffort = "xhigh"
|
||||
)
|
||||
|
||||
func (manager *Manager) SelectChannelForModel(_ context.Context, modelID string) (*legacyruntime.ResolvedChannel, error) {
|
||||
|
||||
33
internal/backend/server/config/revision.go
Normal file
33
internal/backend/server/config/revision.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrConfigConflict = errors.New("CONFIG_CONFLICT: 配置已在其他窗口更改,请重新加载后再保存;当前编辑内容已保留")
|
||||
|
||||
func cloneConfig(cfg Config) Config {
|
||||
cfg.ModelAdapters = append([]ModelAdapterConfig(nil), cfg.ModelAdapters...)
|
||||
if cfg.HomeMetrics.PersistentMode != nil {
|
||||
value := *cfg.HomeMetrics.PersistentMode
|
||||
cfg.HomeMetrics.PersistentMode = &value
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func withRevision(cfg Config) Config {
|
||||
cfg = cloneConfig(cfg)
|
||||
view := cloneConfig(cfg)
|
||||
view.Revision = ""
|
||||
view.LastAgentModelHash = ""
|
||||
for i := range view.ModelAdapters {
|
||||
view.ModelAdapters[i].ID = ""
|
||||
}
|
||||
data, _ := json.Marshal(view)
|
||||
sum := sha256.Sum256(data)
|
||||
cfg.Revision = hex.EncodeToString(sum[:])
|
||||
return cfg
|
||||
}
|
||||
83
internal/backend/server/config/revision_test.go
Normal file
83
internal/backend/server/config/revision_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigCASAndMetadataIsolation(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
m, err := NewManager(context.Background(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := m.Load(nil)
|
||||
b, _ := m.Load(nil)
|
||||
if a.Revision == "" {
|
||||
t.Fatal("missing revision")
|
||||
}
|
||||
if err = m.SaveLastAgentModelHash(nil, "model-new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.CLIHTTPSListenAddr = "127.0.0.1:39099"
|
||||
first, err := m.Save(nil, a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.LastAgentModelHash != "model-new" {
|
||||
t.Fatal("UI overwrote backend metadata")
|
||||
}
|
||||
b.HomeMetrics.RefreshIntervalSeconds = 11
|
||||
if _, err = m.Save(nil, b); !errors.Is(err, ErrConfigConflict) {
|
||||
t.Fatalf("stale save accepted: %v", err)
|
||||
}
|
||||
current, _ := m.Load(nil)
|
||||
if current.CLIHTTPSListenAddr != "127.0.0.1:39099" {
|
||||
t.Fatal("CLI port lost")
|
||||
}
|
||||
data, _ := os.ReadFile(store.Path())
|
||||
if strings.Contains(string(data), "revision:") {
|
||||
t.Fatal("revision persisted in YAML")
|
||||
}
|
||||
if current.Revision == a.Revision {
|
||||
t.Fatal("revision did not change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentSavesHaveExactlyOneWinner(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
|
||||
m, err := NewManager(context.Background(), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := m.Load(nil)
|
||||
b := cloneConfig(a)
|
||||
a.HomeMetrics.RefreshIntervalSeconds = 11
|
||||
b.HomeMetrics.RefreshIntervalSeconds = 12
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, cfg := range []Config{a, b} {
|
||||
wg.Add(1)
|
||||
go func(c Config) { defer wg.Done(); _, err := m.Save(nil, c); results <- err }(cfg)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, ErrConfigConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("success=%d conflict=%d", success, conflict)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"cursor/internal/securefile"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -75,7 +76,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
|
||||
if err := store.saveLocked(defaultConfig); err != nil {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
return defaultConfig, nil
|
||||
return withRevision(defaultConfig), nil
|
||||
}
|
||||
return DefaultConfig(), fmt.Errorf("读取用户配置失败: %w", err)
|
||||
}
|
||||
@@ -93,7 +94,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
|
||||
return DefaultConfig(), err
|
||||
}
|
||||
}
|
||||
return normalized, nil
|
||||
return withRevision(normalized), nil
|
||||
}
|
||||
|
||||
func (store *Store) Save(_ context.Context, cfg Config) (Config, error) {
|
||||
@@ -109,10 +110,20 @@ func (store *Store) Save(_ context.Context, cfg Config) (Config, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
|
||||
if cfg.Revision != "" {
|
||||
existing, err := store.readCurrentLocked()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if withRevision(existing).Revision != cfg.Revision {
|
||||
return Config{}, ErrConfigConflict
|
||||
}
|
||||
normalized.LastAgentModelHash = existing.LastAgentModelHash
|
||||
}
|
||||
if err := store.saveLocked(normalized); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return normalized, nil
|
||||
return withRevision(normalized), nil
|
||||
}
|
||||
|
||||
func (store *Store) saveLocked(normalized Config) error {
|
||||
@@ -125,11 +136,7 @@ func (store *Store) saveLocked(normalized Config) error {
|
||||
return fmt.Errorf("序列化用户配置失败: %w", err)
|
||||
}
|
||||
|
||||
tempPath := store.path + ".tmp"
|
||||
if err := writeFileSync(tempPath, data); err != nil {
|
||||
return fmt.Errorf("写入临时配置失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tempPath, store.path); err != nil {
|
||||
if err := securefile.Write(store.path, data); err != nil {
|
||||
return fmt.Errorf("保存用户配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -181,3 +188,31 @@ func yamlHasKey(raw []byte, key string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (store *Store) readCurrentLocked() (Config, error) {
|
||||
data, err := os.ReadFile(store.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var current Config
|
||||
if err = yaml.Unmarshal(data, ¤t); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return NormalizeConfig(current)
|
||||
}
|
||||
func (store *Store) updateLastAgentModelHash(value string) (Config, error) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
cfg, err := store.readCurrentLocked()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.LastAgentModelHash = value
|
||||
if err = store.saveLocked(cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return withRevision(cfg), nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"cursor/internal/logger"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -62,6 +63,7 @@ type HomeMetricsConfig struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Revision string `json:"revision" yaml:"-"`
|
||||
Log bool `json:"log" yaml:"log"`
|
||||
ProviderStreamIdleTimeout int `json:"providerStreamIdleTimeout" yaml:"providerStreamIdleTimeout"`
|
||||
BackendListenAddr string `json:"backendListenAddr" yaml:"backendListenAddr"`
|
||||
@@ -139,6 +141,13 @@ func NormalizeConfig(input Config) (Config, error) {
|
||||
}
|
||||
output.TabServerBaseURL = normalizeTabServerURL(input.TabServerBaseURL)
|
||||
output.TabServerToken = strings.TrimSpace(input.TabServerToken)
|
||||
if strings.TrimSpace(input.TabServerBaseURL) != "" && output.TabServerBaseURL == "" {
|
||||
return Config{}, fmt.Errorf("tabServerBaseURL 无效")
|
||||
}
|
||||
if output.TabServerBaseURL != "" && output.TabServerToken == "" {
|
||||
return Config{}, fmt.Errorf("配置 Tab 服务时必须提供独立 tabServerToken")
|
||||
}
|
||||
logger.RegisterSecrets(output.TabServerToken)
|
||||
adapters, err := NormalizeModelAdapterConfigs(input.ModelAdapters)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -156,7 +165,7 @@ func normalizeTabServerURL(value string) string {
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
if parsed.User != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
||||
@@ -193,6 +202,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
AnthropicMaxTokens: normalizeMaxCompletionTokens(item.AnthropicMaxTokens),
|
||||
ThinkingBudgetTokens: normalizeMaxCompletionTokens(item.ThinkingBudgetTokens),
|
||||
}
|
||||
logger.RegisterSecrets(next.APIKey)
|
||||
if next.Type == "openai" {
|
||||
next.OpenAIExtraParamsEnabled = item.OpenAIExtraParamsEnabled
|
||||
next.OpenAIExtraParamsJSON = strings.TrimSpace(item.OpenAIExtraParamsJSON)
|
||||
@@ -203,6 +213,15 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
|
||||
}
|
||||
next.CustomHeadersEnabled = item.CustomHeadersEnabled
|
||||
next.CustomHeadersJSON = strings.TrimSpace(item.CustomHeadersJSON)
|
||||
var headers map[string]string
|
||||
if json.Unmarshal([]byte(next.CustomHeadersJSON), &headers) == nil {
|
||||
for key, value := range headers {
|
||||
lower := strings.ToLower(key)
|
||||
if strings.Contains(lower, "auth") || strings.Contains(lower, "key") || strings.Contains(lower, "token") || strings.Contains(lower, "cookie") {
|
||||
logger.RegisterSecrets(value, strings.TrimPrefix(value, "Bearer "))
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case next.DisplayName == "":
|
||||
return nil, errors.New("模型适配器 displayName 不能为空")
|
||||
|
||||
Reference in New Issue
Block a user