Ð޸´ȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9

¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档
Ð޸´ Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£
Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£

Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢
Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£
Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档
This commit is contained in:
FuDie0915
2026-11-10 09:59:34 +08:00
parent db73cf5a39
commit 188225fae6
98 changed files with 3324 additions and 930 deletions

View File

@@ -2,7 +2,6 @@
`internal/backend` 当前支持本地助手模式与直连上游模式。
关于 backend agent「最小事实集合」的第一阶段研究文档,见 [`../../docs/backend-agent-minimum-facts-phase1.md`](../../docs/backend-agent-minimum-facts-phase1.md)。
核心边界:
@@ -98,7 +97,6 @@ internal/backend/
- `~/.cursor-local-assistant-v2/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/`
- `~/.cursor-local-assistant-v2/logs/`
@@ -106,7 +104,6 @@ internal/backend/
- `config.yaml` 是用户配置
- `data/ca.crt` 是注入给宿主的 CA 证书
- `data/ads/` 是广告包与资源缓存目录
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志
- `logs/` 只保留必要文本运行日志

View File

@@ -5,6 +5,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"google.golang.org/protobuf/proto"
"html"
"io"
"mime"
@@ -325,7 +326,7 @@ func (bridge *Bridge) openSwitchMode(toolCall runtimecore.ToolInvocation) (*agen
},
},
}
argsPayload, _ := json.Marshal(args)
argsPayload, _ := json.Marshal(&args)
return serverMessage, runtimecore.PendingInteraction{
InteractionID: fmt.Sprintf("%d", messageID),
ArgsJSON: argsPayload,
@@ -679,7 +680,7 @@ func truncateWebSearchReplay(searchTerm string, references []*agentv1.WebSearchR
if reference == nil {
continue
}
next := *reference
next := proto.Clone(reference).(*agentv1.WebSearchReference)
title := truncateInteractionText("WebSearch title", next.GetTitle(), webSearchTitleLimit)
chunk := truncateInteractionText("WebSearch snippet", next.GetChunk(), webSearchChunkLimit)
if title != next.GetTitle() || chunk != next.GetChunk() {
@@ -687,7 +688,7 @@ func truncateWebSearchReplay(searchTerm string, references []*agentv1.WebSearchR
}
next.Title = title
next.Chunk = chunk
nextReferences = append(nextReferences, &next)
nextReferences = append(nextReferences, next)
}
nextPayload := formatWebSearchPayload(searchTerm, nextReferences)
if strings.TrimSpace(payload) != "" && len(nextPayload) == 0 {

View File

@@ -0,0 +1,94 @@
package modeladapter
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestProvidersForwardAuthenticationOverridesAndFinishStream(t *testing.T) {
for _, provider := range []string{"openai", "anthropic"} {
t.Run(provider, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Gateway-Key") != "fixture-gateway" || r.Host != "gateway.fixture" {
t.Error("custom headers not applied")
}
if provider == "openai" && r.Header.Get("Authorization") != "Bearer fixture-key" {
t.Error("OpenAI authentication missing")
}
if provider == "anthropic" && r.Header.Get("x-api-key") != "fixture-key" {
t.Error("Anthropic authentication missing")
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if body["model"] != "fixture-model" {
t.Error("model mapping lost")
}
w.Header().Set("Content-Type", "text/event-stream")
if provider == "openai" {
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")
} else {
for _, event := range []string{`{"type":"message_start","message":{"id":"fixture","type":"message","role":"assistant","content":[],"model":"fixture-model","usage":{"input_tokens":1,"output_tokens":0}}}`, `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`, `{"type":"content_block_stop","index":0}`, `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}`, `{"type":"message_stop"}`} {
var envelope struct {
Type string `json:"type"`
}
_ = json.Unmarshal([]byte(event), &envelope)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", envelope.Type, event)
}
}
}))
defer server.Close()
req := StreamRequest{BaseURL: server.URL, APIKey: "fixture-key", ProviderModelID: "fixture-model", ModelID: "fixture-model", OpenAIEndpoint: "/v1/chat/completions", Messages: []Message{{Role: "user", Content: "hello"}}, MaxTokens: 32, Stream: true, CustomHeadersEnabled: true, CustomHeadersJSON: `{"X-Gateway-Key":"fixture-gateway","Host":"gateway.fixture"}`}
var text strings.Builder
finished := false
sink := func(event ModelEvent) error {
if event.Kind == ModelEventKindTextDelta {
text.WriteString(event.Text)
}
if event.Kind == ModelEventKindTurnFinished {
finished = true
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var err error
if provider == "openai" {
adapter := NewOpenAIAdapter()
adapter.client = server.Client()
err = adapter.Stream(ctx, req, sink)
} else {
adapter := NewAnthropicAdapter()
adapter.client = server.Client()
err = adapter.Stream(ctx, req, sink)
}
if err != nil {
t.Fatal(err)
}
if text.String() != "ok" || !finished {
t.Fatalf("stream did not finish correctly: %q %t", text.String(), finished)
}
})
}
}
func TestProviderDeadlineCancelsPendingHTTPResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(io.Discard, r.Body); <-r.Context().Done() }))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
_, err := DoProviderRequestWithRetry(ctx, server.Client(), "fixture", "", "", func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodPost, server.URL, strings.NewReader("fixture"))
})
if err == nil || ctx.Err() == nil {
t.Fatal("provider ignored cancellation")
}
}

View File

@@ -85,7 +85,11 @@ func ApplyCustomHeaders(httpReq *http.Request, enabled bool, headersJSON string)
if name == "" {
continue
}
httpReq.Header.Set(name, value)
if strings.EqualFold(name, "Host") {
httpReq.Host = value
} else {
httpReq.Header.Set(name, value)
}
}
return nil
}

View File

@@ -3,6 +3,7 @@ package modeladapter
import (
"context"
"cursor/internal/netproxy"
"net/http"
)
@@ -33,7 +34,7 @@ func doProviderRequestWithRetry(
if err != nil {
return nil, err
}
resp, err := client.Do(httpReq)
resp, err := netproxy.CredentialSafeClient(client).Do(httpReq)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()

View File

@@ -746,7 +746,16 @@ func validateConversationID(conversationID string) (string, error) {
if normalized == "" {
return "", fmt.Errorf("conversation_id is required")
}
if strings.Contains(normalized, "/") || strings.Contains(normalized, string(os.PathSeparator)) {
base := strings.ToUpper(strings.Split(normalized, ".")[0])
if base == "CON" || base == "PRN" || base == "AUX" || base == "NUL" || (len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) && base[3] >= '1' && base[3] <= '9') {
return "", fmt.Errorf("conversation_id is a reserved filename")
}
for _, r := range normalized {
if r < 32 || r == 127 {
return "", fmt.Errorf("invalid conversation_id")
}
}
if normalized == "." || normalized == ".." || !filepath.IsLocal(normalized) || strings.ContainsAny(normalized, "/\\:\x00<>\"|?*") || strings.TrimRight(normalized, " .") != normalized {
return "", fmt.Errorf("conversation_id must not contain path separators")
}
return normalized, nil

View File

@@ -0,0 +1,20 @@
package forwarder
import "testing"
func TestConversationIDRejectsTraversalAndWindowsAliases(t *testing.T) {
for _, id := range []string{"", ".", "..", "../outside", `..\outside`, "C:evil", "CON", "id.", "id ", "a\x00b", "/absolute"} {
// Whitespace is trimmed for compatibility; the remaining alias must be safe.
if id == "id " {
continue
}
if _, err := validateConversationID(id); err == nil {
t.Errorf("accepted %q", id)
}
}
for _, id := range []string{"conversation-123", "a_b.c"} {
if _, err := validateConversationID(id); err != nil {
t.Fatal(err)
}
}
}

View File

@@ -1,6 +1,7 @@
package forwarder
import (
"fmt"
"path/filepath"
"testing"
"time"
@@ -27,7 +28,7 @@ func TestUpsertEventIdempotentBeyondRecentLimit(t *testing.T) {
// 填充超过 RecentEvents 上限的无关事件,把目标事件挤出 RecentEvents。
for i := range usageRecentEventLimit + 10 {
if err := store.UpsertEvent(usageFileEvent{
EventID: "filler",
EventID: fmt.Sprintf("filler-%d", i),
Kind: usageEventKindProvider,
At: base.Add(time.Duration(i+1) * time.Second),
InputTokens: 1,

View File

@@ -19,6 +19,7 @@ import (
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/backend/server/upstream"
"cursor/internal/certs"
"cursor/internal/localaccess"
"cursor/internal/logger"
"cursor/internal/netproxy"
legacyruntime "cursor/internal/runtime"
@@ -27,21 +28,22 @@ import (
const healthPath = "/healthz"
type Host struct {
store *serverconfig.Store
listenAddr string
lifecycleMu sync.Mutex
store *serverconfig.Store
listenAddr string
cliHTTPSListenAddr string
tabServerBaseURL string
configs *serverconfig.Manager
healthHTTP *http.Client
certManager *certs.Manager
tabServerBaseURL string
configs *serverconfig.Manager
healthHTTP *http.Client
certManager *certs.Manager
runMu sync.RWMutex
httpServer *http.Server
httpsServer *http.Server
runMu sync.RWMutex
httpServer *http.Server
httpsServer *http.Server
lastRunErr error
lastRunErr error
mux http.Handler
mux http.Handler
}
func NewHost(store *serverconfig.Store, certManager *certs.Manager) (*Host, error) {
@@ -54,13 +56,13 @@ func NewHost(store *serverconfig.Store, certManager *certs.Manager) (*Host, erro
}
cfg := configs.Current()
host := &Host{
store: store,
listenAddr: cfg.BackendListenAddr,
store: store,
listenAddr: cfg.BackendListenAddr,
cliHTTPSListenAddr: cfg.CLIHTTPSListenAddr,
tabServerBaseURL: cfg.TabServerBaseURL,
configs: configs,
healthHTTP: newLoopbackHTTPClient(),
certManager: certManager,
tabServerBaseURL: cfg.TabServerBaseURL,
configs: configs,
healthHTTP: newLoopbackHTTPClient(),
certManager: certManager,
}
if err := host.rebuild(cfg); err != nil {
return nil, err
@@ -86,6 +88,8 @@ func (host *Host) SaveConfig(ctx context.Context, cfg serverconfig.Config) (serv
if host == nil || host.configs == nil {
return serverconfig.Config{}, fmt.Errorf("backend config manager is not initialized")
}
host.lifecycleMu.Lock()
defer host.lifecycleMu.Unlock()
normalized, err := host.configs.Save(ctx, cfg)
if err != nil {
return serverconfig.Config{}, err
@@ -157,6 +161,8 @@ func (host *Host) Start() error {
if host == nil {
return fmt.Errorf("backend host is nil")
}
host.lifecycleMu.Lock()
defer host.lifecycleMu.Unlock()
cfg := host.configs.Current()
host.runMu.Lock()
@@ -170,7 +176,7 @@ func (host *Host) Start() error {
httpServer := &http.Server{
Addr: host.listenAddr,
Handler: host.mux,
Handler: localaccess.Wrap(host.mux),
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", host.listenAddr)
@@ -199,8 +205,10 @@ func (host *Host) Start() error {
// 启动 CLI HTTPS+HTTP/2 监听器
if err := host.startHTTPSLocked(cfg); err != nil {
// HTTPS 失败不阻断 HTTP,仅记录
logger.Errorf("启动 CLI HTTPS 监听器失败: %v", err)
host.httpServer = nil
_ = httpServer.Close()
host.lastRunErr = fmt.Errorf("启动 CLI HTTPS 监听器失败: %w", err)
return host.lastRunErr
}
return nil
@@ -230,7 +238,7 @@ func (host *Host) startHTTPSLocked(cfg serverconfig.Config) error {
httpsServer := &http.Server{
Addr: addr,
Handler: host.mux,
Handler: localaccess.Wrap(host.mux),
TLSConfig: tlsConfig,
ReadHeaderTimeout: 10 * time.Second,
}
@@ -262,28 +270,29 @@ func (host *Host) Stop(ctx context.Context) error {
if host == nil {
return nil
}
host.runMu.Lock()
httpServer := host.httpServer
httpsServer := host.httpsServer
host.httpServer = nil
host.httpsServer = nil
host.runMu.Unlock()
host.lifecycleMu.Lock()
defer host.lifecycleMu.Unlock()
host.runMu.RLock()
httpServer, httpsServer := host.httpServer, host.httpsServer
host.runMu.RUnlock()
var errs []error
if httpsServer != nil {
if err := httpsServer.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("stop CLI HTTPS: %w", err))
for _, srv := range []*http.Server{httpsServer, httpServer} {
if srv != nil {
if err := srv.Shutdown(ctx); err != nil {
_ = srv.Close()
errs = append(errs, err)
}
}
}
if serverInstance := httpServer; serverInstance != nil {
err := serverInstance.Shutdown(ctx)
if err != nil && !errors.Is(err, context.Canceled) {
errs = append(errs, err)
}
host.runMu.Lock()
if host.httpServer == httpServer {
host.httpServer = nil
}
if len(errs) > 0 {
return errors.Join(errs...)
if host.httpsServer == httpsServer {
host.httpsServer = nil
}
return nil
host.runMu.Unlock()
return errors.Join(errs...)
}
func (host *Host) HealthCheck(ctx context.Context) error {
@@ -928,19 +937,27 @@ func uploadServiceProcedure(pattern string, name string, protocol server.RouteOp
func tabServerUpstreamProcedure(pattern string, name string, protocol server.RouteOption, deps upstream.Dependencies, host *Host) server.Option {
direct := upstream.DirectAction(deps, upstream.CompatRouteConfig{Name: name})
action := func(ctx *server.Context) error {
if host == nil || host.tabServerBaseURL == "" {
if host == nil || ctx == nil || ctx.Request == nil {
return fmt.Errorf("invalid tab request")
}
cfg := host.configs.Current()
if cfg.TabServerBaseURL == "" {
http.NotFound(ctx.Writer, ctx.Request)
return nil
}
if ctx != nil && ctx.Request != nil && ctx.Request.URL != nil {
baseURL, err := url.Parse(host.tabServerBaseURL)
baseURL, err := url.Parse(cfg.TabServerBaseURL)
if err != nil {
return fmt.Errorf("解析 tab server 地址失败: %w", err)
}
targetURL := *ctx.Request.URL
targetURL.Scheme = baseURL.Scheme
targetURL.Host = baseURL.Host
targetURL.Path = strings.TrimRight(baseURL.Path, "/") + ctx.Request.URL.Path
targetURL.RawPath = ""
ctx.UpstreamURL = &targetURL
ctx.Request = ctx.Request.Clone(ctx.Request.Context())
ctx.Request.Header.Set("Authorization", "Bearer "+cfg.TabServerToken)
}
return direct(ctx)
}

View File

@@ -0,0 +1,105 @@
package backend
import (
"context"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs"
"errors"
"net"
"net/http"
"path/filepath"
"testing"
"time"
)
func freeAddress(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := l.Addr().String()
l.Close()
return addr
}
func TestHTTPSBindFailureRollsBackHTTPListener(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer occupied.Close()
cfg := serverconfig.DefaultConfig()
cfg.BackendListenAddr = freeAddress(t)
cfg.CLIHTTPSListenAddr = occupied.Addr().String()
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
if _, err = store.Save(nil, cfg); err != nil {
t.Fatal(err)
}
ca, err := certs.LoadOrCreateLocalCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
host, err := NewHost(store, ca)
if err != nil {
t.Fatal(err)
}
if err = host.Start(); err == nil {
t.Fatal("HTTPS bind failure ignored")
}
if host.IsRunning() {
t.Fatal("partial service reported running")
}
listener, err := net.Listen("tcp", cfg.BackendListenAddr)
if err != nil {
t.Fatal("HTTP listener leaked", err)
}
listener.Close()
}
func TestShutdownTimeoutForceClosesConnectionAndReleasesPort(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
address := listener.Addr().String()
started := make(chan struct{})
ended := make(chan struct{})
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done(); close(ended) })}
go server.Serve(listener)
host := &Host{httpServer: server}
requestDone := make(chan struct{})
go func() {
defer close(requestDone)
client := &http.Client{Timeout: time.Second}
resp, _ := client.Get("http://" + address)
if resp != nil {
resp.Body.Close()
}
}()
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("request not started")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
err = host.Stop(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected timeout, got %v", err)
}
select {
case <-ended:
case <-time.After(time.Second):
t.Fatal("handler still active")
}
<-requestDone
if host.IsRunning() {
t.Fatal("closed service still running")
}
rebound, err := net.Listen("tcp", address)
if err != nil {
t.Fatal("port not released", err)
}
rebound.Close()
}

View File

@@ -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))
}
}
}

View File

@@ -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) {

View 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
}

View 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)
}
}

View File

@@ -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, &current); 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
}

View File

@@ -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 不能为空")

View File

@@ -0,0 +1,65 @@
package backend
import (
"bytes"
"cursor/internal/backend/server"
serverconfig "cursor/internal/backend/server/config"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
func TestTabCallerTokenAndBasePathFollowCurrentConfiguration(t *testing.T) {
expected := "client-first"
calls := 0
tab := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer "+expected {
t.Error("configured caller token not forwarded")
}
if r.URL.Path != "/base/aiserver.v1.AiService/CppConfig" {
t.Error("base path lost", r.URL.Path)
}
w.WriteHeader(204)
}))
defer tab.Close()
store := serverconfig.NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
cfg := serverconfig.DefaultConfig()
cfg.TabServerBaseURL = tab.URL + "/base"
cfg.TabServerToken = expected
if _, err := store.Save(nil, cfg); err != nil {
t.Fatal(err)
}
host, err := NewHost(store, nil)
if err != nil {
t.Fatal(err)
}
local := httptest.NewServer(host.mux)
defer local.Close()
call := func() {
r, _ := http.NewRequest(http.MethodPost, local.URL+"/aiserver.v1.AiService/CppConfig", bytes.NewReader(nil))
r.Header.Set("Content-Type", "application/proto")
r.Header.Set(server.HeaderServerUpstreamURL, "https://api2.cursor.sh/aiserver.v1.AiService/CppConfig")
r.Header.Set("Authorization", "Bearer unrelated-cursor-token")
response, err := local.Client().Do(r)
if err != nil {
t.Fatal(err)
}
response.Body.Close()
if response.StatusCode != 204 {
t.Fatal(response.StatusCode)
}
}
call()
cfg = host.ConfigManager().Current()
expected = "client-second"
cfg.TabServerToken = expected
if _, err = host.SaveConfig(nil, cfg); err != nil {
t.Fatal(err)
}
call()
if calls != 2 {
t.Fatal("Tab requests did not reach configured server")
}
}