Ð޸´ȫÏîĿÉó²éÎÊÌⲢ·¢²¼ 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

@@ -15,6 +15,7 @@ import (
"github.com/leaanthony/u"
"cursor/internal/appdata"
bridge "cursor/internal/bridge"
"cursor/internal/certs"
"cursor/internal/cursor"
@@ -32,6 +33,8 @@ const (
// EmbeddedResources 定义了当前模块中的 EmbeddedResources 类型。
type EmbeddedResources struct {
// DisableAutoStart opens the UI without changing proxy settings or certificate trust.
DisableAutoStart bool
// Assets 表示当前声明中的 Assets。
Assets fs.FS
// AppIcon 表示当前声明中的 AppIcon。
@@ -50,17 +53,18 @@ func init() {
// Run 用于处理与 Run 相关的逻辑。
func Run(resources EmbeddedResources) error {
logger.Init()
ensureStartMenuShortcut()
if !resources.DisableAutoStart {
ensureStartMenuShortcut()
}
netproxy.InstallDefaultTransport()
embeddedCACertPEM := certs.EmbeddedCACertPEM()
logEmbeddedCAInfo(embeddedCACertPEM)
certManager, err := certs.NewEmbeddedManager()
certManager, err := certs.LoadOrCreateLocalCA(appdata.DataRootPath())
if err != nil {
return err
}
embeddedCACertPEM := certManager.CACertPEM()
logEmbeddedCAInfo(embeddedCACertPEM)
defaultBackendBaseURL := "http://" + serverconfig.DefaultBackendListenAddr
proxyServer, err := mitm.NewProxyServer(serverconfig.DefaultProxyListenAddr, defaultBackendBaseURL, "", "", certManager)
if err != nil {
@@ -90,7 +94,12 @@ func Run(resources EmbeddedResources) error {
proxyService.ShutdownForQuit()
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "com.local-curosr.single-instance",
UniqueID: func() string {
if resources.DisableAutoStart {
return "com.local-cursor.readonly-ui"
}
return "com.local-curosr.single-instance"
}(),
OnSecondInstanceLaunch: func(data application.SecondInstanceData) {
logger.Infof("检测到实例请求,已忽略")
// 不激活窗口,避免干扰用户工作
@@ -200,6 +209,9 @@ func Run(resources EmbeddedResources) error {
})
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(event *application.ApplicationEvent) {
logger.Infof("应用版本:v%s", buildinfo.CurrentVersion())
if resources.DisableAutoStart {
return
}
go func() {
logger.Infof("application started, begin auto start service in background")
if _, err := proxyService.StartProxy(); err != nil {
@@ -244,17 +256,17 @@ func Run(resources EmbeddedResources) error {
// logEmbeddedCAInfo 用于处理与 logEmbeddedCAInfo 相关的逻辑。
func logEmbeddedCAInfo(certPEM []byte) {
if len(certPEM) == 0 {
logger.Errorf("embedded CA is empty")
logger.Errorf("local CA is empty")
return
}
cert, err := parseEmbeddedCert(certPEM)
if err != nil {
logger.Errorf("parse embedded CA failed: %v", err)
logger.Errorf("parse local CA failed: %v", err)
return
}
sum := sha256.Sum256(cert.Raw)
logger.Infof(
"embedded CA loaded: sha256=%s subject=%s valid=%s~%s",
"local CA loaded: sha256=%s subject=%s valid=%s~%s",
strings.ToUpper(hex.EncodeToString(sum[:])),
cert.Subject.String(),
cert.NotBefore.Format(time.RFC3339),

View File

@@ -37,8 +37,11 @@ func ensureStartMenuShortcut() {
return
}
if err := os.MkdirAll(filepath.Dir(lnkPath), 0700); err != nil {
return
}
workDir := filepath.Dir(exePath)
psScript := `$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('` + lnkPath + `'); $s.TargetPath = '` + exePath + `'; $s.WorkingDirectory = '` + workDir + `'; $s.Description = 'Local-Cursor'; $s.Save()`
psScript := `$ws = New-Object -ComObject WScript.Shell; $s = $ws.CreateShortcut('` + strings.ReplaceAll(lnkPath, "'", "''") + `'); $s.TargetPath = '` + strings.ReplaceAll(exePath, "'", "''") + `'; $s.WorkingDirectory = '` + strings.ReplaceAll(workDir, "'", "''") + `'; $s.Description = 'Local-Cursor'; $s.Save()`
cmd := winexec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", psScript)
if err := cmd.Run(); err != nil {
logger.Infof("ensureStartMenuShortcut: create failed lnk=%s err=%v", lnkPath, err)

View File

@@ -1,6 +1,7 @@
package appdata
import (
"cursor/internal/securefile"
"fmt"
"io"
"os"
@@ -9,19 +10,19 @@ import (
func ensureAssistantHome() error {
migrateLegacyAssistantHome()
if err := os.MkdirAll(RootDir(), 0o755); err != nil {
if err := securefile.PrivateDir(RootDir()); err != nil {
return fmt.Errorf("create assistant home: %w", err)
}
if err := os.MkdirAll(DataRootPath(), 0o755); err != nil {
if err := securefile.PrivateDir(DataRootPath()); err != nil {
return fmt.Errorf("create data root: %w", err)
}
if err := os.MkdirAll(HistoryRootPath(), 0o755); err != nil {
if err := securefile.PrivateDir(HistoryRootPath()); err != nil {
return fmt.Errorf("create history root: %w", err)
}
if err := os.MkdirAll(RulesRootPath(), 0o755); err != nil {
if err := securefile.PrivateDir(RulesRootPath()); err != nil {
return fmt.Errorf("create rules root: %w", err)
}
if err := os.MkdirAll(LogsRootPath(), 0o755); err != nil {
if err := securefile.PrivateDir(LogsRootPath()); err != nil {
return fmt.Errorf("create logs root: %w", err)
}
return nil

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

View File

@@ -25,11 +25,6 @@ import (
//go:embed ca.crt
var embeddedCACertPEM []byte
// embeddedCAKeyPEM 表示当前模块中的 embeddedCAKeyPEM 状态值。
//
//go:embed ca.key
var embeddedCAKeyPEM []byte
// Manager 定义了当前模块中的 Manager 类型。
type Manager struct {
// caCert 表示当前声明中的 caCert。
@@ -52,27 +47,23 @@ func NewManager(caCertPath, caKeyPath string) (*Manager, error) {
return NewManagerFromPEM(certPEM, keyPEM)
}
// NewEmbeddedManager 用于处理与 NewEmbeddedManager 相关的逻辑。
func NewEmbeddedManager() (*Manager, error) {
return NewManagerFromPEM(embeddedCACertPEM, embeddedCAKeyPEM)
}
// EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。
func EmbeddedCACertPEM() []byte {
return cloneBytes(embeddedCACertPEM)
}
// EmbeddedCAKeyPEM 用于处理与 EmbeddedCAKeyPEM 相关的逻辑。
func EmbeddedCAKeyPEM() []byte {
return cloneBytes(embeddedCAKeyPEM)
}
// NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。
func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) {
if _, err := tls.X509KeyPair(caCertPEM, caKeyPEM); err != nil {
return nil, err
}
caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM)
if err != nil {
return nil, err
}
if !caCert.IsCA || caCert.KeyUsage&x509.KeyUsageCertSign == 0 || time.Now().Before(caCert.NotBefore) || !time.Now().Before(caCert.NotAfter) {
return nil, errors.New("invalid or expired CA certificate")
}
return &Manager{caCert: caCert, caKey: caKey, cache: make(map[string]*tls.Certificate)}, nil
}
@@ -98,7 +89,7 @@ func (m *Manager) CertificateForServerName(serverName string) (*tls.Certificate,
m.mu.Lock()
defer m.mu.Unlock()
if cert, ok := m.cache[host]; ok {
if cert, ok := m.cache[host]; ok && cert.Leaf != nil && time.Now().Add(time.Minute).Before(cert.Leaf.NotAfter) {
return cert, nil
}
@@ -119,6 +110,12 @@ func (m *Manager) CertificateForServerName(serverName string) (*tls.Certificate,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if !time.Now().Before(m.caCert.NotAfter) {
return nil, errors.New("CA certificate expired")
}
if leaf.NotAfter.After(m.caCert.NotAfter) {
leaf.NotAfter = m.caCert.NotAfter
}
if len(m.caCert.SubjectKeyId) > 0 {
leaf.AuthorityKeyId = append([]byte(nil), m.caCert.SubjectKeyId...)
}

View File

@@ -1,27 +0,0 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyh3jND/aFusuRjGTmhQtX2hkF1qroNjEEKCxWPlfprvdl8Tx
upxNv1TQcm+K9KsS7OxnKYP4Qtv068XLbaCCGuoA/xpor6enrT85KulBq0j8z/g1
y0VWxjz3xN9F9ND13h9yxDZCn76egbRkhFxpXow67jLsIlrqWDSltERlTKh2cJ1g
hTRuQNSr7jtKQgFAR3aQ6dTzOP4fCOtLYn63jL4+YcxdGoK66tx8eFHq8oBLYvsc
LNjjkaAHilZFwA4Jr7zHBofTRBg04eZum2UTaRqmSyT65ifXm4vRdQa4k1FS1gnq
hPMhIjST8b6RkxvbLNmFpkOfI9eCMRpFG7y+NQIDAQABAoIBAATU9ZVOcHmLSkop
zcBJerM09O2dAIziGb/XA55fqdJ728aQ0gGW0oIANlKCCaWjQFrTJP04VzNL/F01
l5EpnOqlTPxMRpPqc2cAI677sBL29fpH0gtnvzUSiI7Xkp3RcAtNH6qCrJGSlkn+
BMgoSGmW+yKuK3h/yWnt6kc2umA8fN+bHKhS3pI56PMW8qVnny9n92RaCA7Uf/4j
XDewIreiH5jRqRwrPbOpjDFmv+W18LWZQiTwwxfY6sRZiZpsfsHidzfFGUFZXMlq
2P3FCqoF4oMM1rRgBlHhDR7JHmSkFpZG639HJTXLllpyDbj3H86nsjIj9WZYKn+h
B9k9bcECgYEA1HDzRCqoRZh6cL46KiYjv+LwmEKMa3nPb4ljywgLbRkzTMyVs0MK
fDsDoTLFY9PBhveypU5gjTbQqtyBsDtbU+dH9Eks1FdL9P8bJf0ZOJFRiEB7uB4a
z3V9tcXHwH4l2bCWbWThQGFwRudDAoY0EH89oSA/WHayjOKe1Wi4ovUCgYEA848B
cYi+Qbkk+fOv9gSJn8KS1LH/jE28S/e7E4YkTfYUuu+7wr8bdRKUNpPIsLX0Fo9R
KpJX0Oyjjady9n/8ARZRmD8Upl+F7Sl7Ro6F8+nfqQUrxbVDiIL3b+aZF+cDfFrB
/xL5kyZZqTtFfP360tfYnlS6Sssd4E2Jsj0fJkECgYEA0gG+WbqZkgMDtwQ114jQ
elZLZRkUWwKVjzsQDZssQHNTBS6RJh619M0Z73aTLvYcL+IZFdT/GVoAuYc2JRLo
W28c8F6OFHMfwVeWbN1g20y8fqbQJtiLxF3vIYwcxStvG123tvisu8oXBeCDm7Ez
MsO2FtwcAsWECEXWojzdmSkCgYEA18GvPawtHnuszd+Z2Q5b/DKZb+Hex6N1Ura5
+qmyL333D0Kfyf0RjbxPn6l690+4UuPSuyu4r1Nx72KO7N6jlzL2RTBcUqX8NgOx
OOe4skJT5561EAdrM9sQ5wgYRpxW8ipUAGoGvNwUQV5ISFmVgIHFWz0jam5UoQcP
G94ZYgECgYBN4PB8BuAwMRqhTCLL5RJKtcdC/Ls9xdWrmswtQQ4OSBUTLGdeMhEN
E23F0d+NdtiTWDYRDJ7z6KAF8CcOwWMt2sNbrxgNRuDzyfLqbuVLOM8iY3xA50Nl
clZWujKILWp47+gA6/AqJLv2lA2LK8gM8zYzzm/nCuLCBLYH/hOiCA==
-----END RSA PRIVATE KEY-----

78
internal/certs/local.go Normal file
View File

@@ -0,0 +1,78 @@
package certs
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
"cursor/internal/securefile"
)
// LoadOrCreateLocalCA persists one private CA per application data directory.
// A single private bundle is the authority; ca.crt is a public export for clients.
func LoadOrCreateLocalCA(dir string) (*Manager, error) {
if err := securefile.PrivateDir(dir); err != nil {
return nil, err
}
unlock, err := securefile.Lock(filepath.Join(dir, "ca.lock"))
if err != nil {
return nil, err
}
defer unlock()
bundlePath := filepath.Join(dir, "local-ca.pem")
bundle, err := os.ReadFile(bundlePath)
if errors.Is(err, os.ErrNotExist) {
key, keyErr := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if keyErr != nil {
return nil, keyErr
}
serial, keyErr := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if keyErr != nil {
return nil, keyErr
}
now := time.Now()
template := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: "Local-Cursor Private CA", Organization: []string{"Local-Cursor"}}, NotBefore: now.Add(-time.Hour), NotAfter: now.AddDate(10, 0, 0), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign}
der, keyErr := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if keyErr != nil {
return nil, keyErr
}
keyPEM, keyErr := marshalPrivateKeyPEM(key)
if keyErr != nil {
return nil, keyErr
}
bundle = append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), keyPEM...)
if err = securefile.Write(bundlePath, bundle); err != nil {
return nil, err
}
} else if err != nil {
return nil, err
}
if err = securefile.Protect(bundlePath); err != nil {
return nil, err
}
certBlock, rest := pem.Decode(bundle)
if certBlock == nil {
return nil, fmt.Errorf("本机 CA 文件损坏,请先备份并修复")
}
manager, err := NewManagerFromPEM(pem.EncodeToMemory(certBlock), rest)
if err != nil {
return nil, fmt.Errorf("本机 CA 校验失败(不会自动轮换): %w", err)
}
if err = securefile.Write(filepath.Join(dir, "ca.crt"), manager.CACertPEM()); err != nil {
return nil, err
}
return manager, nil
}
func (m *Manager) CACertPEM() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: m.caCert.Raw})
}

View File

@@ -0,0 +1,70 @@
package certs
import (
"bytes"
"encoding/pem"
"os"
"path/filepath"
"testing"
)
func TestLocalCAStablePrivateAndIndependent(t *testing.T) {
dir := t.TempDir()
a, err := LoadOrCreateLocalCA(dir)
if err != nil {
t.Fatal(err)
}
b, err := LoadOrCreateLocalCA(dir)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(a.CACertPEM(), b.CACertPEM()) {
t.Fatal("CA rotated on restart")
}
c, err := LoadOrCreateLocalCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
if bytes.Equal(a.CACertPEM(), c.CACertPEM()) {
t.Fatal("installations share a CA")
}
if bytes.Equal(a.CACertPEM(), EmbeddedCACertPEM()) {
t.Fatal("legacy CA reused")
}
leaf, err := a.CertificateForServerName("api2.cursor.sh")
if err != nil {
t.Fatal(err)
}
if err := leaf.Leaf.CheckSignatureFrom(a.caCert); err != nil {
t.Fatal(err)
}
if err := leaf.Leaf.VerifyHostname("api2.cursor.sh"); err != nil {
t.Fatal(err)
}
if err := leaf.Leaf.VerifyHostname("unrelated.example"); err == nil {
t.Fatal("hostname mismatch accepted")
}
bundle, err := os.ReadFile(filepath.Join(dir, "local-ca.pem"))
if err != nil {
t.Fatal(err)
}
_, key := pem.Decode(bundle)
if _, err = NewManagerFromPEM(c.CACertPEM(), key); err == nil {
t.Fatal("mismatched key accepted")
}
}
func TestCorruptCADoesNotRotateOrOverwrite(t *testing.T) {
dir := t.TempDir()
bad := []byte("broken private authority")
if err := os.WriteFile(filepath.Join(dir, "local-ca.pem"), bad, 0600); err != nil {
t.Fatal(err)
}
if _, err := LoadOrCreateLocalCA(dir); err == nil {
t.Fatal("corrupt CA accepted")
}
after, _ := os.ReadFile(filepath.Join(dir, "local-ca.pem"))
if !bytes.Equal(bad, after) {
t.Fatal("corrupt CA silently replaced")
}
}

View File

@@ -2,6 +2,7 @@ package client
import (
"context"
"fmt"
"cursor/internal/appdata"
serverconfig "cursor/internal/backend/server/config"
@@ -22,8 +23,8 @@ func (s *ProxyService) LoadUserConfig() (UserConfig, error) {
if app != nil {
ctx = app.Context()
}
if s.backendHost != nil {
return s.backendHost.LoadConfig(ctx)
if s.backend() != nil {
return s.backend().LoadConfig(ctx)
}
if s.store == nil {
return serverconfig.DefaultConfig(), nil
@@ -36,6 +37,9 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
if s == nil {
return nil
}
if cfg.Revision == "" {
return fmt.Errorf("配置缺少 revision,请重新加载后保存")
}
app := application.Get()
ctx := context.Background()
if app != nil {
@@ -45,8 +49,8 @@ func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
normalized UserConfig
err error
)
if s.backendHost != nil {
normalized, err = s.backendHost.SaveConfig(ctx, cfg)
if s.backend() != nil {
normalized, err = s.backend().SaveConfig(ctx, cfg)
} else if s.store != nil {
normalized, err = s.store.Save(ctx, cfg)
} else {

View File

@@ -1,6 +1,7 @@
package client
import (
"errors"
"fmt"
goruntime "runtime"
@@ -10,7 +11,7 @@ import (
// ApplyCursorSettings 用于处理与 ApplyCursorSettings 相关的逻辑。
func (s *ProxyService) ApplyCursorSettings() error {
if s == nil || s.proxy == nil {
if s == nil || s.proxyServer() == nil {
return fmt.Errorf("proxy is not initialized")
}
s.caFileMu.Lock()
@@ -37,7 +38,7 @@ func (s *ProxyService) ApplyCursorSettings() error {
}
}
proxyURL := cursor.ProxyURLFromListenAddr(s.proxy.Snapshot().ListenAddr)
proxyURL := cursor.ProxyURLFromListenAddr(s.proxyServer().Snapshot().ListenAddr)
if err := cursor.WriteUserProxySettings(proxyURL); err != nil {
return err
@@ -48,8 +49,8 @@ func (s *ProxyService) ApplyCursorSettings() error {
// 如果未检测到 CLI 安装,跳过但不报错
if cliPath := cursor.DetectCLIInstallPath(); cliPath != "" {
cliHTTPSAddr := ""
if s.backendHost != nil {
cliHTTPSAddr = s.backendHost.CLIHTTPSListenAddr()
if s.backend() != nil {
cliHTTPSAddr = s.backend().CLIHTTPSListenAddr()
}
if err := cursor.WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath); err != nil {
logger.Errorf("write cli proxy wrapper failed: %v", err)
@@ -68,22 +69,21 @@ func (s *ProxyService) ApplyCursorSettings() error {
// ClearCursorSettings 用于处理与 ClearCursorSettings 相关的逻辑。
func (s *ProxyService) ClearCursorSettings() error {
var errs []error
if goruntime.GOOS == "darwin" {
if err := cursor.ClearSystemNodeExtraCACerts(); err != nil {
return err
errs = append(errs, err)
}
}
if err := cursor.ClearUserProxySettings(); err != nil {
return err
for _, clear := range []func() error{cursor.ClearUserProxySettings, cursor.ClearCLIProxyWrapper, cursor.ClearCLIAuthFile} {
if err := clear(); err != nil {
errs = append(errs, err)
}
}
if err := cursor.ClearCLIProxyWrapper(); err != nil {
logger.Errorf("clear cli proxy wrapper failed: %v", err)
if len(errs) == 0 {
s.setCursorSettingsApplied(false)
}
if err := cursor.ClearCLIAuthFile(); err != nil {
logger.Errorf("clear cli auth.json failed: %v", err)
}
s.setCursorSettingsApplied(false)
return nil
return errors.Join(errs...)
}
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。

View File

@@ -58,8 +58,34 @@ type ProxyState struct {
// StartProxy 用于处理与 StartProxy 相关的逻辑。
func (s *ProxyService) StartProxy() (ProxyState, error) {
if s == nil {
return ProxyState{}, fmt.Errorf("服务未初始化")
}
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
if s.backend() != nil && s.backend().IsRunning() && s.proxyServer() != nil && s.proxyServer().IsRunning() {
return s.GetState(), nil
}
startedBackend, startedProxy, settingsAttempted := false, false, false
rollback := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if startedProxy && s.proxyServer() != nil {
_ = s.proxyServer().Stop(ctx)
}
if settingsAttempted {
if err := s.ClearCursorSettings(); err != nil {
logger.Error("startup rollback failed", "error", err)
}
}
if startedBackend && s.backend() != nil {
_ = s.backend().Stop(ctx)
}
}
logger.Infof("start service requested config_path=%s logs_root=%s", s.configPath, s.logsRoot)
fail := func(step string, err error) (ProxyState, error) {
rollback()
logger.Errorf("start service failed step=%s err=%v", step, err)
s.setLastError(err)
s.emitState()
@@ -72,51 +98,43 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
if err := s.ensureBackendHost(); err != nil {
return fail("ensure_backend_host", err)
}
if !s.backendHost.IsRunning() {
logger.Infof("starting embedded backend listen_addr=%s", s.backendHost.ListenAddr())
if err := s.backendHost.Start(); err != nil {
if !s.backend().IsRunning() {
logger.Infof("starting embedded backend listen_addr=%s", s.backend().ListenAddr())
if err := s.backend().Start(); err != nil {
return fail("start_backend", err)
}
startedBackend = true
} else {
logger.Infof("embedded backend already running listen_addr=%s", s.backendHost.ListenAddr())
logger.Infof("embedded backend already running listen_addr=%s", s.backend().ListenAddr())
}
healthCtx, healthCancel := context.WithTimeout(context.Background(), backendReadyTimeout)
defer healthCancel()
if err := s.waitForBackend(healthCtx); err != nil {
return fail("wait_backend_ready", err)
}
logger.Infof("embedded backend ready listen_addr=%s", s.backendHost.ListenAddr())
logger.Infof("embedded backend ready listen_addr=%s", s.backend().ListenAddr())
if err := s.ensureProxy(cfg); err != nil {
return fail("ensure_proxy", err)
}
if s.proxyServer() != nil && !s.proxyServer().IsRunning() {
logger.Infof("starting mitm proxy listen_addr=%s", s.proxyServer().Snapshot().ListenAddr)
if err := s.proxyServer().Start(); err != nil {
return fail("start_mitm_proxy", err)
}
startedProxy = true
}
settingsAttempted = true
if err := s.ApplyCursorSettings(); err != nil {
return fail("apply_cursor_settings", err)
}
// 启动时注入账号信息
if err := cursor.InjectCursorUserInfo(localruntime.InjectAccountEmail, localruntime.InjectAuthToken); err != nil {
logger.Errorf("injectCursorUserInfo failed: %v", err)
// 不阻断启动,仅记录日志
}
if s.proxy != nil && !s.proxy.IsRunning() {
logger.Infof("starting mitm proxy listen_addr=%s", s.proxy.Snapshot().ListenAddr)
if err := s.proxy.Start(); err != nil {
return fail("start_mitm_proxy", err)
}
}
if err := s.ApplyCursorSettings(); err != nil {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
if s.proxy != nil {
_ = s.proxy.Stop(stopCtx)
}
_ = s.backendHost.Stop(stopCtx)
startErr := fmt.Errorf("服务已启动,但注入 Cursor 配置失败: %w", err)
logger.Errorf("start service failed step=apply_cursor_settings err=%v", startErr)
s.setLastError(startErr)
s.emitState()
return s.GetState(), startErr
}
s.setLastError(nil)
s.emitState()
state := s.GetState()
@@ -133,6 +151,11 @@ func (s *ProxyService) StartProxy() (ProxyState, error) {
// 各清理步骤失败时记录错误但继续执行后续步骤,保证 backend 最终被停止,
// 避免"Cursor 配置已清理但 backend 仍在运行"的不一致状态。
func (s *ProxyService) StopProxy() (ProxyState, error) {
if s == nil {
return ProxyState{}, nil
}
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
logger.Infof("stop service requested")
var stopErrors []error
fail := func(step string, err error) {
@@ -144,15 +167,15 @@ func (s *ProxyService) StopProxy() (ProxyState, error) {
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if s.proxy != nil && s.proxy.IsRunning() {
logger.Infof("stopping mitm proxy listen_addr=%s", s.proxy.Snapshot().ListenAddr)
fail("stop_mitm_proxy", s.proxy.Stop(ctx))
if s.proxyServer() != nil && s.proxyServer().IsRunning() {
logger.Infof("stopping mitm proxy listen_addr=%s", s.proxyServer().Snapshot().ListenAddr)
fail("stop_mitm_proxy", s.proxyServer().Stop(ctx))
}
fail("clear_cursor_settings", s.ClearCursorSettings())
if s.backendHost != nil {
logger.Infof("stopping embedded backend listen_addr=%s", s.backendHost.ListenAddr())
fail("stop_backend", s.backendHost.Stop(ctx))
if s.backend() != nil {
logger.Infof("stopping embedded backend listen_addr=%s", s.backend().ListenAddr())
fail("stop_backend", s.backend().Stop(ctx))
}
if len(stopErrors) > 0 {
@@ -177,8 +200,8 @@ func (s *ProxyService) StopProxy() (ProxyState, error) {
// GetState 用于处理与 GetState 相关的逻辑。
func (s *ProxyService) GetState() ProxyState {
var proxySnap mitm.Snapshot
if s.proxy != nil {
proxySnap = s.proxy.Snapshot()
if s.proxyServer() != nil {
proxySnap = s.proxyServer().Snapshot()
}
s.mu.RLock()
lastError := s.lastError
@@ -188,11 +211,11 @@ func (s *ProxyService) GetState() ProxyState {
backendRunning := false
cliHTTPSListenAddr := ""
cliHTTPSRunning := false
if s.backendHost != nil {
backendListenAddr = s.backendHost.ListenAddr()
backendRunning = s.backendHost.IsRunning()
cliHTTPSListenAddr = s.backendHost.CLIHTTPSListenAddr()
cliHTTPSRunning = s.backendHost.CLIHTTPSRunning()
if s.backend() != nil {
backendListenAddr = s.backend().ListenAddr()
backendRunning = s.backend().IsRunning()
cliHTTPSListenAddr = s.backend().CLIHTTPSListenAddr()
cliHTTPSRunning = s.backend().CLIHTTPSRunning()
}
netProxy := netproxy.CurrentStatus()
return ProxyState{
@@ -263,20 +286,27 @@ func (s *ProxyService) emitState() {
// ShutdownForQuit 用于处理与 ShutdownForQuit 相关的逻辑。
func (s *ProxyService) ShutdownForQuit() {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var finalErr error
if s.proxy != nil {
if err := s.proxy.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
if s.proxyServer() != nil {
if err := s.proxyServer().Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
finalErr = err
}
}
if err := s.ClearCursorSettings(); err != nil {
finalErr = errors.Join(finalErr, err)
s.mu.RLock()
applied := s.cursorSettingsApplied
s.mu.RUnlock()
if applied {
if err := s.ClearCursorSettings(); err != nil {
finalErr = errors.Join(finalErr, err)
}
}
if s.backendHost != nil {
if err := s.backendHost.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
if s.backend() != nil {
if err := s.backend().Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
finalErr = errors.Join(finalErr, err)
}
}

View File

@@ -0,0 +1,33 @@
package client
import (
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs"
"fmt"
"sync"
"testing"
)
func TestStatePollingDuringProxyInitialization(t *testing.T) {
ca, err := certs.LoadOrCreateLocalCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
service := &ProxyService{certManager: ca}
cfg := serverconfig.DefaultConfig()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for range 300 {
_ = service.GetState()
}
}()
for i := range 30 {
cfg.ProxyListenAddr = fmt.Sprintf("127.0.0.1:%d", 30000+i)
if err := service.ensureProxy(cfg); err != nil {
t.Fatal(err)
}
}
wg.Wait()
}

View File

@@ -9,7 +9,9 @@ import (
"strings"
"time"
"cursor/internal/logger"
"cursor/internal/modelchannel"
"cursor/internal/netproxy"
)
// RemoteModel 表示从模型服务 /models 端点拉取到的一个模型。
@@ -33,6 +35,7 @@ func (s *ProxyService) ListRemoteModels(adapterType string, baseURL string, apiK
adapterType = strings.ToLower(strings.TrimSpace(adapterType))
baseURL = strings.TrimSpace(baseURL)
apiKey = strings.TrimSpace(apiKey)
logger.RegisterSecrets(apiKey)
if adapterType != "openai" && adapterType != "anthropic" {
return result, fmt.Errorf("模型类型仅支持 openai 或 anthropic")
}
@@ -63,7 +66,7 @@ func (s *ProxyService) ListRemoteModels(adapterType string, baseURL string, apiK
}
request.Header.Set("Accept", "application/json")
response, err := http.DefaultClient.Do(request)
response, err := netproxy.CredentialSafeClient(http.DefaultClient).Do(request)
if err != nil {
return result, fmt.Errorf("请求模型列表失败: %w", err)
}

View File

@@ -73,4 +73,4 @@ func TestBuildModelsEndpointDefaults(t *testing.T) {
if !strings.HasPrefix(buildModelsEndpoint("openai", "https://example.com/v1"), "https://example.com/v1/") {
t.Fatalf("/v1 结尾去重后应保留原路径前缀")
}
}
}

View File

@@ -11,8 +11,8 @@ func (s *ProxyService) runtimeConfigSnapshot(_ context.Context) (localruntime.Ru
if s == nil {
return localruntime.RuntimeConfigSnapshot{}, nil
}
if s.backendHost != nil && s.backendHost.ConfigManager() != nil {
return s.backendHost.ConfigManager().LegacyRuntimeSnapshot(context.Background())
if s.backend() != nil && s.backend().ConfigManager() != nil {
return s.backend().ConfigManager().LegacyRuntimeSnapshot(context.Background())
}
if s.store == nil {
return localruntime.RuntimeConfigSnapshot{}, nil

View File

@@ -29,6 +29,7 @@ const (
// ProxyService 定义了当前模块中的 ProxyService 类型。
type ProxyService struct {
lifecycleMu sync.Mutex
// proxy 表示 IDE 版 MITM 代理。
proxy *mitm.ProxyServer
// certManager 用于在代理监听地址变化时重建 MITM 服务,同时为 CLI HTTPS 监听器签发证书。
@@ -98,34 +99,44 @@ func (s *ProxyService) ensureBackendHost() error {
if s == nil {
return nil
}
if s.backendHost != nil {
s.configMu.Lock()
defer s.configMu.Unlock()
if s == nil {
return nil
}
if s.backend() != nil {
return nil
}
host, err := backend.NewHost(s.store, s.certManager)
if err != nil {
return err
}
s.backendHost = host
s.setBackend(host)
return nil
}
func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error {
if s == nil {
return nil
}
s.configMu.Lock()
defer s.configMu.Unlock()
if s == nil {
return nil
}
baseURL := ""
if s.backendHost != nil {
baseURL = s.backendHost.BaseURL()
if s.backend() != nil {
baseURL = s.backend().BaseURL()
}
if baseURL == "" {
baseURL = "http://" + cfg.BackendListenAddr
}
listenAddr := cfg.ProxyListenAddr
if s.proxy != nil {
snapshot := s.proxy.Snapshot()
if s.proxyServer() != nil {
snapshot := s.proxyServer().Snapshot()
if snapshot.ListenAddr == listenAddr {
return s.proxy.UpdateBaseURL(baseURL)
return s.proxyServer().UpdateBaseURL(baseURL)
}
if snapshot.Running {
return fmt.Errorf("代理正在运行,不能从 %s 切换到 %s,请先停止服务", snapshot.ListenAddr, listenAddr)
@@ -136,12 +147,12 @@ func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error {
if err != nil {
return err
}
s.proxy = proxyServer
s.setProxy(proxyServer)
return nil
}
func (s *ProxyService) waitForBackend(ctx context.Context) error {
if s == nil || s.backendHost == nil {
if s == nil || s.backend() == nil {
return nil
}
ticker := time.NewTicker(backendHealthCheckInterval)
@@ -149,7 +160,7 @@ func (s *ProxyService) waitForBackend(ctx context.Context) error {
var lastErr error
for {
healthCtx, healthCancel := context.WithTimeout(ctx, backendHealthCheckAttemptTimeout)
err := s.backendHost.HealthCheck(healthCtx)
err := s.backend().HealthCheck(healthCtx)
healthCancel()
if err == nil {
return nil
@@ -165,3 +176,24 @@ func (s *ProxyService) waitForBackend(ctx context.Context) error {
}
}
}
func (s *ProxyService) backend() *backend.Host {
s.mu.RLock()
defer s.mu.RUnlock()
return s.backendHost
}
func (s *ProxyService) proxyServer() *mitm.ProxyServer {
s.mu.RLock()
defer s.mu.RUnlock()
return s.proxy
}
func (s *ProxyService) setBackend(host *backend.Host) {
s.mu.Lock()
defer s.mu.Unlock()
s.backendHost = host
}
func (s *ProxyService) setProxy(proxy *mitm.ProxyServer) {
s.mu.Lock()
defer s.mu.Unlock()
s.proxy = proxy
}

View File

@@ -0,0 +1,37 @@
package cursor
import (
"cursor/internal/certs"
"fmt"
)
// migrateCATrust verifies both sides of the migration. Removing the legacy
// trust is necessary because its private key has already been public.
func migrateCATrust(cert []byte, trusted func([]byte) (bool, error), migrate func() error) error {
current, err := trusted(cert)
if err != nil {
return err
}
legacy, err := trusted(certs.EmbeddedCACertPEM())
if err != nil {
return err
}
if current && !legacy {
return nil
}
if err := migrate(); err != nil {
return err
}
current, err = trusted(cert)
if err != nil {
return err
}
legacy, err = trusted(certs.EmbeddedCACertPEM())
if err != nil {
return err
}
if !current || legacy {
return fmt.Errorf("证书迁移未完成:需要信任本机 CA 并移除旧共享 CA;请重试授权")
}
return nil
}

View File

@@ -4,12 +4,15 @@
package cursor
import (
"cursor/internal/securefile"
_ "embed"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"cursor/internal/appdata"
@@ -68,88 +71,10 @@ func DetectCLIInstallPath() string {
return ""
}
// cliPreloadFilename 是 preload 脚本的文件名。
const cliPreloadFilename = "tls-bypass.cjs"
const cliPreloadFilename = "tls-redirect.cjs"
// cliPreloadScript 是注入到 Node.js 的 preload 脚本。
//
// 工作原理:
// 1. patch net.connect — 将 cursor.sh 域名的 TCP 连接重定向到本地后端 HTTPS 端口
// 2. patch tls.connect — 强制 rejectUnauthorized=false,使后端签发的 CA 证书被接受
//
// CLI 的 connect-node 使用 HTTP/2 (http2.connect),goproxy 无法 MITM HTTP/2 帧。
// 因此跳过 MITM 代理,直接在 TCP 层重定向到后端的 HTTPS+HTTP/2 监听器。
// 后端 Go http.Server.ServeTLS 原生支持 HTTP/2 ALPN。
const cliPreloadScript = `// Generated by Local-Curosr: redirect cursor.sh TCP to local HTTPS backend
'use strict';
const tls = require('tls');
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const logPath = path.join(os.homedir(), '.cursor-local-assistant-v2', 'logs', 'tls-bypass.log');
function dbg(msg) {
try { fs.appendFileSync(logPath, new Date().toISOString() + ' ' + msg + '\n'); } catch(e) {}
}
var REDIRECT_PORT = parseInt(process.env.CLI_HTTPS_PORT || '39092', 10);
var REDIRECT_HOST = '127.0.0.1';
var REDIRECT_DOMAINS = ['api2.cursor.sh', 'api3.cursor.sh'];
function shouldRedirect(host) {
if (!host) return false;
host = String(host).toLowerCase().trim();
for (var i = 0; i < REDIRECT_DOMAINS.length; i++) {
var d = REDIRECT_DOMAINS[i];
if (host === d || host.endsWith('.' + d)) return true;
}
return false;
}
// --- patch net.connect: TCP 层重定向 ---
var origNetConnect = net.connect;
function patchedNetConnect() {
if (typeof arguments[0] === 'object' && arguments[0] !== null) {
var opts = arguments[0];
if (shouldRedirect(opts.host)) {
dbg('net.connect redirect: ' + opts.host + ':' + opts.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
opts.host = REDIRECT_HOST;
opts.port = REDIRECT_PORT;
}
} else if (typeof arguments[0] === 'number' && typeof arguments[1] === 'string') {
if (shouldRedirect(arguments[1])) {
dbg('net.connect redirect (positional): ' + arguments[1] + ':' + arguments[0] + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
arguments[1] = REDIRECT_HOST;
arguments[0] = REDIRECT_PORT;
}
}
return origNetConnect.apply(this, arguments);
}
net.connect = patchedNetConnect;
net.createConnection = patchedNetConnect;
// --- patch tls.connect: 强制 rejectUnauthorized=false + 重定向 ---
var origTLSConnect = tls.connect;
tls.connect = function() {
for (var i = 0; i < arguments.length && i < 3; i++) {
if (arguments[i] && typeof arguments[i] === 'object' && !Array.isArray(arguments[i])) {
var o = arguments[i];
o.rejectUnauthorized = false;
if (o.host && shouldRedirect(o.host)) {
dbg('tls.connect redirect: ' + o.host + ':' + o.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
o.host = REDIRECT_HOST;
o.port = REDIRECT_PORT;
if (!o.servername) o.servername = o.host;
}
break;
}
}
return origTLSConnect.apply(this, arguments);
};
dbg('tls-bypass preload loaded, redirect=' + REDIRECT_HOST + ':' + REDIRECT_PORT);
`
//go:embed cli_preload.cjs
var cliPreloadScript string
// writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。
func writeCLIPreloadScript() (string, error) {
@@ -158,7 +83,7 @@ func writeCLIPreloadScript() (string, error) {
return "", fmt.Errorf("创建 preload 目录失败: %w", err)
}
preloadPath := filepath.Join(wrapperDir, cliPreloadFilename)
if err := os.WriteFile(preloadPath, []byte(cliPreloadScript), 0o644); err != nil {
if err := securefile.Write(preloadPath, []byte(cliPreloadScript)); err != nil {
return "", fmt.Errorf("写入 preload 脚本失败: %w", err)
}
return preloadPath, nil
@@ -207,15 +132,21 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
var content string
if runtime.GOOS == "windows" {
content = buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath)
if err := securefile.Write(filepath.Join(wrapperDir, "launch.ps1"), append([]byte{0xef, 0xbb, 0xbf}, []byte(buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath))...)); err != nil {
return err
}
} else {
content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath)
}
wrapperPath := CLIWrapperPath()
if err := os.WriteFile(wrapperPath, []byte(content), 0o755); err != nil {
if err := securefile.Write(wrapperPath, []byte(content)); err != nil {
return fmt.Errorf("写入 wrapper 脚本失败: %w", err)
}
if err := os.Chmod(wrapperPath, 0700); err != nil {
return err
}
logger.Infof(
"writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s",
wrapperPath, port, caCertPath, cliPath, preloadPath,
@@ -225,6 +156,11 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
// ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。
func ClearCLIProxyWrapper() error {
for _, ownedName := range []string{"launch.ps1", "tls-bypass.cjs"} {
if err := os.Remove(filepath.Join(CLIWrapperDir(), ownedName)); err != nil && !os.IsNotExist(err) {
return err
}
}
wrapperPath := CLIWrapperPath()
if _, err := os.Stat(wrapperPath); err != nil {
if !os.IsNotExist(err) {
@@ -387,31 +323,41 @@ func ClearCLIAuthFile() error {
}
func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
cliDir := filepath.Dir(cliPath)
psScript := filepath.Join(cliDir, "cursor-agent.ps1")
return fmt.Sprintf(`@echo off
setlocal
set "HTTP_PROXY="
set "HTTPS_PROXY="
set "CLI_HTTPS_PORT=%s"
set "NODE_EXTRA_CA_CERTS=%s"
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
set "NODE_OPTIONS=--require=%s"
set "CURSOR_AUTH_TOKEN=%s"
set "CURSOR_INVOKED_AS=agent.cmd"
%%SystemRoot%%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%s" %%*
`, port, caCertPath, preloadPath, authToken, psScript)
return "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"%~dp0launch.ps1\" %*\r\n"
}
func buildWindowsLauncher(port, caCertPath, authToken, cliPath, preloadPath string) string {
psScript := filepath.Join(filepath.Dir(cliPath), "cursor-agent.ps1")
return fmt.Sprintf(`$ErrorActionPreference='Stop'
$env:HTTP_PROXY=''
$env:HTTPS_PROXY=''
$env:CLI_HTTPS_PORT=%s
$env:LOCAL_CURSOR_ORIGINAL_CA_FILE=$env:NODE_EXTRA_CA_CERTS
$env:NODE_EXTRA_CA_CERTS=%s
if($env:LOCAL_CURSOR_ORIGINAL_CA_FILE -eq $env:NODE_EXTRA_CA_CERTS){$env:LOCAL_CURSOR_ORIGINAL_CA_FILE=''}
Remove-Item Env:NODE_TLS_REJECT_UNAUTHORIZED -ErrorAction SilentlyContinue
if($env:NODE_OPTIONS -match 'tls-bypass[.]cjs'){$env:NODE_OPTIONS=''}
$env:NODE_OPTIONS=($env:NODE_OPTIONS + ' ' + %s).Trim()
$env:CURSOR_AUTH_TOKEN=%s
$env:CURSOR_INVOKED_AS='agent.cmd'
& %s @args
exit $LASTEXITCODE
`, psLiteral(port), psLiteral(caCertPath), psLiteral("--require="+strconv.Quote(preloadPath)), psLiteral(authToken), psLiteral(psScript))
}
func psLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
func shellLiteral(s string) string { return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" }
func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
return fmt.Sprintf(`#!/bin/sh
unset HTTP_PROXY
unset HTTPS_PROXY
export CLI_HTTPS_PORT="%s"
export NODE_EXTRA_CA_CERTS="%s"
export NODE_TLS_REJECT_UNAUTHORIZED=0
export NODE_OPTIONS="--require=%s"
export CURSOR_AUTH_TOKEN="%s"
exec "%s" "$@"
`, port, caCertPath, preloadPath, authToken, cliPath)
unset HTTP_PROXY HTTPS_PROXY NODE_TLS_REJECT_UNAUTHORIZED
export CLI_HTTPS_PORT=%s
export LOCAL_CURSOR_ORIGINAL_CA_FILE="${NODE_EXTRA_CA_CERTS-}"
export NODE_EXTRA_CA_CERTS=%s
if [ "$LOCAL_CURSOR_ORIGINAL_CA_FILE" = "$NODE_EXTRA_CA_CERTS" ]; then unset LOCAL_CURSOR_ORIGINAL_CA_FILE; fi
case "${NODE_OPTIONS-}" in *tls-bypass.cjs*) unset NODE_OPTIONS;; esac
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }"%s
export CURSOR_AUTH_TOKEN=%s
exec %s "$@"
`, shellLiteral(port), shellLiteral(caCertPath), shellLiteral("--require="+strconv.Quote(preloadPath)), shellLiteral(authToken), shellLiteral(cliPath))
}

View File

@@ -0,0 +1,43 @@
// Generated by Local-Cursor. Redirect only Cursor API hosts; keep TLS verification.
'use strict';
const tls = require('node:tls');
const net = require('node:net');
const fs = require('node:fs');
const {X509Certificate} = require('node:crypto');
const port = Number(process.env.CLI_HTTPS_PORT || 39092);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid CLI HTTPS port');
const localCA = process.env.NODE_EXTRA_CA_CERTS ? fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS) : undefined;
const originalCAPath = process.env.LOCAL_CURSOR_ORIGINAL_CA_FILE;
const extraCA = originalCAPath ? (fs.readFileSync(originalCAPath, 'utf8').match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || []).filter(cert => new X509Certificate(cert).fingerprint256.replaceAll(':','').toLowerCase() !== '836e6bb84f6c3e63316dbb4ec257223af09f7490e7aae09030b8515ed61ee9ff') : [];
const redirect = host => /^(?:[a-z0-9-]+\.)*api[23]\.cursor\.sh$/i.test(String(host || ''));
function normalized(args) {
if (args[0] && typeof args[0] === 'object') return { options: { ...args[0] }, callback: args.find(a => typeof a === 'function') };
const extra = args.find(a => a && typeof a === 'object') || {};
return { options: { ...extra, port: args[0], ...(typeof args[1] === 'string' ? {host: args[1]} : {}) }, callback: args.find(a => typeof a === 'function') };
}
const originalNetConnect = net.connect;
function connect(...args) {
const {options, callback} = normalized(args);
if (!redirect(options.host)) return originalNetConnect.apply(this, args);
options.host = '127.0.0.1'; options.port = port;
return originalNetConnect.call(this, options, ...(callback ? [callback] : []));
}
net.connect = net.createConnection = connect;
const originalTLSConnect = tls.connect;
tls.connect = function (...args) {
const {options, callback} = normalized(args);
if (!redirect(options.host)) {
if (!extraCA.length || options.ca !== undefined || options.secureContext) return originalTLSConnect.apply(this, args);
const defaults = typeof tls.getCACertificates === 'function' ? tls.getCACertificates('default') : tls.rootCertificates;
options.ca = [...defaults, ...extraCA];
return originalTLSConnect.call(this, options, ...(callback ? [callback] : []));
}
const originalHost = options.host;
options.host = '127.0.0.1'; options.port = port;
options.servername = options.servername || originalHost;
options.rejectUnauthorized = true;
if (!options.ca && extraCA.length) options.ca = [...(typeof tls.getCACertificates === "function" ? tls.getCACertificates("default") : tls.rootCertificates), ...extraCA];
if (options.ca && localCA) options.ca = [...(Array.isArray(options.ca) ? options.ca : [options.ca]), localCA];
return originalTLSConnect.call(this, options, ...(callback ? [callback] : []));
};

View File

@@ -0,0 +1,89 @@
package cursor
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"cursor/internal/certs"
)
func TestCLIHTTP2TrustSNIAndUnrelatedTLSRejection(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Fatal("Node.js is required for CLI TLS integration tests")
}
dir := filepath.Join(t.TempDir(), "中文 space ' test")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
manager, err := certs.LoadOrCreateLocalCA(dir)
if err != nil {
t.Fatal(err)
}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ProtoMajor != 2 {
t.Error("not HTTP/2")
}
_, _ = w.Write([]byte("secure-http2"))
}))
server.EnableHTTP2 = true
server.TLS = &tls.Config{GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
return manager.CertificateForServerName(h.ServerName)
}}
server.StartTLS()
defer server.Close()
u, _ := url.Parse(server.URL)
preload := filepath.Join(dir, cliPreloadFilename)
if err = os.WriteFile(preload, []byte(cliPreloadScript), 0600); err != nil {
t.Fatal(err)
}
good := `const h=require('node:http2');const s=h.connect('https://api2.cursor.sh');s.on('error',e=>{console.error(e.code);process.exitCode=1});const r=s.request({':path':'/'});let b='';r.on('data',d=>b+=d);r.on('end',()=>{if(b!=='secure-http2')process.exitCode=2;s.close()});r.end();`
bad := fmt.Sprintf(`const t=require('node:tls');const s=t.connect({host:'127.0.0.1',port:%s},()=>{console.error('accepted invalid host');s.destroy();process.exitCode=2});s.on('error',()=>process.exit(0));`, u.Port())
for _, script := range []string{good, bad} {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
cmd := exec.CommandContext(ctx, "node", "-e", script)
cmd.Env = append(os.Environ(), "CLI_HTTPS_PORT="+u.Port(), "NODE_EXTRA_CA_CERTS="+filepath.Join(dir, "ca.crt"), "NODE_OPTIONS=--require="+strconv.Quote(preload), "NODE_TLS_REJECT_UNAUTHORIZED=")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
t.Fatalf("CLI integration: %v %s", err, out)
}
}
wrong, err := certs.LoadOrCreateLocalCA(t.TempDir())
if err != nil {
t.Fatal(err)
}
wrongPath := filepath.Join(dir, "wrong.crt")
if err = os.WriteFile(wrongPath, wrong.CACertPEM(), 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "node", "-e", `const h=require('node:http2');const s=h.connect('https://api2.cursor.sh');s.on('connect',()=>{s.close();process.exitCode=2});s.on('error',()=>process.exit(0));`)
cmd.Env = append(os.Environ(), "CLI_HTTPS_PORT="+u.Port(), "NODE_EXTRA_CA_CERTS="+wrongPath, "NODE_OPTIONS=--require="+strconv.Quote(preload), "NODE_TLS_REJECT_UNAUTHORIZED=")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("wrong-CA rejection: %v %s", err, out)
}
}
func TestWrapperQuotesPathsAndDoesNotDisableTLS(t *testing.T) {
path := `C:\用户 space\O'Brien\tls-redirect.cjs`
ps := buildWindowsLauncher("39092", `C:\用户 space\ca.crt`, "test-token", `C:\用户 space\agent.cmd`, path)
if strings.Contains(ps, "NODE_TLS_REJECT_UNAUTHORIZED=0") || !strings.Contains(ps, "O''Brien") || !strings.Contains(ps, "--require=") {
t.Fatal("unsafe PowerShell launcher")
}
sh := buildUnixWrapper("39092", "/tmp/user ' space/ca.crt", "test-token", "/tmp/bin ' /agent", "/tmp/user ' space/preload.cjs")
if strings.Contains(sh, "REJECT_UNAUTHORIZED=0") || !strings.Contains(sh, `'"'"'`) {
t.Fatal("unsafe shell wrapper")
}
}

View File

@@ -5,6 +5,7 @@ package cursor
import (
"crypto/sha1"
"crypto/x509"
"cursor/internal/certs"
"encoding/pem"
"fmt"
"os/exec"
@@ -84,15 +85,23 @@ func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
// EnsureCACertInstalled 确保 CA 证书已安装到 macOS 登录钥匙串。
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("检查 macOS 证书安装状态失败: %w", err)
}
if installed {
logger.Infof("ensureCACertInstalled: cert already installed in macOS login keychain, skipping")
return migrateCATrust(certPEM, isCACertInstalled, func() error {
if err := installCACertToDarwinKeychain(certPEM, certPath); err != nil {
return err
}
legacy, err := getCertSHA1Fingerprint(certs.EmbeddedCACertPEM())
if err != nil {
return err
}
exists, err := isCACertInstalled(certs.EmbeddedCACertPEM())
if err != nil {
return err
}
if exists {
if out, err := exec.Command(darwinSecurityExe, "delete-certificate", "-t", "-Z", legacy, darwinLoginKeychainName).CombinedOutput(); err != nil {
return fmt.Errorf("移除旧共享证书失败: %w: %s", err, strings.TrimSpace(string(out)))
}
}
return nil
}
logger.Infof("ensureCACertInstalled: cert not installed in macOS login keychain, installing...")
return installCACertToDarwinKeychain(certPEM, certPath)
})
}

View File

@@ -0,0 +1,77 @@
//go:build windows
package cursor
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func utf8BOM(text string) []byte { return append([]byte{0xef, 0xbb, 0xbf}, []byte(text)...) }
func TestPowerShellLauncherPreservesUnicodePathsAndArguments(t *testing.T) {
dir := filepath.Join(t.TempDir(), "中文 space O'Brien")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
result := filepath.Join(dir, "result.json")
fake := `$payload=@{args=@($args);ca=$env:NODE_EXTRA_CA_CERTS;options=$env:NODE_OPTIONS;tls=$env:NODE_TLS_REJECT_UNAUTHORIZED};[IO.File]::WriteAllText(` + psLiteral(result) + `,($payload|ConvertTo-Json -Compress),[Text.Encoding]::UTF8);exit 0`
if err := os.WriteFile(filepath.Join(dir, "cursor-agent.ps1"), utf8BOM(fake), 0600); err != nil {
t.Fatal(err)
}
preload := filepath.Join(dir, "tls-redirect.cjs")
launcher := filepath.Join(dir, "launch.ps1")
if err := os.WriteFile(launcher, utf8BOM(buildWindowsLauncher("39092", filepath.Join(dir, "ca.crt"), "fixture-token", filepath.Join(dir, "agent.cmd"), preload)), 0600); err != nil {
t.Fatal(err)
}
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", launcher, "--message", "one two", "a&b", "O'Brien")
cmd.Env = append(os.Environ(), "NODE_TLS_REJECT_UNAUTHORIZED=0", "NODE_OPTIONS=--require=old-tls-bypass.cjs")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("launcher: %v %s", err, out)
}
data, err := os.ReadFile(result)
if err != nil {
t.Fatal(err)
}
data = []byte(strings.TrimPrefix(string(data), "\ufeff"))
var got struct {
Args []string `json:"args"`
CA string `json:"ca"`
Options string `json:"options"`
TLS string `json:"tls"`
}
if err = json.Unmarshal(data, &got); err != nil {
t.Fatal(err)
}
want := []string{"--message", "one two", "a&b", "O'Brien"}
if len(got.Args) != len(want) {
t.Fatalf("arguments: %#v", got.Args)
}
for i := range want {
if got.Args[i] != want[i] {
t.Fatalf("argument %d: %q", i, got.Args[i])
}
}
if got.CA != filepath.Join(dir, "ca.crt") || got.TLS != "" || strings.Contains(got.Options, "tls-bypass") {
t.Fatal("unsafe launcher environment")
}
}
func TestMigrationPowerShellSyntaxWithoutExecutingTrustChanges(t *testing.T) {
body := windowsCAMigrationBody(`C:\用户 space O'Brien\ca.crt`, strings.Repeat("A", 40), strings.Repeat("B", 40))
path := filepath.Join(t.TempDir(), "migration.ps1")
if err := os.WriteFile(path, utf8BOM(body), 0600); err != nil {
t.Fatal(err)
}
parse := `$tokens=$null;$errors=$null;$null=[Management.Automation.Language.Parser]::ParseFile(` + psLiteral(path) + `,[ref]$tokens,[ref]$errors);if($errors.Count){$errors|ForEach-Object{$_.Message};exit 1}`
if out, err := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", parse).CombinedOutput(); err != nil {
t.Fatalf("invalid migration syntax: %v %s", err, out)
}
if strings.Contains(body, "CurrentUser") {
t.Fatal("elevated script must not target the alternate administrator's user store")
}
}

View File

@@ -14,3 +14,8 @@ func mergeNodeSearchPath() (string, bool) {
func detectCursorInstallPathFromRegistry() string {
return ""
}
// scanDriveRootsForCursor is only used as a Windows fallback.
func scanDriveRootsForCursor() string {
return ""
}

View File

@@ -179,8 +179,16 @@ func DeleteProjectRecord(projectPath string) error {
return fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
}
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
return deleteProjectRecords(userDir, projectPath)
}
func deleteProjectRecords(userDir, projectPath string) error {
// Never delete chat data before confirming that the recent-list database can be updated.
entryRemoved, entryErr := removeRecentlyOpenedEntry(userDir, projectPath)
if entryErr != nil {
return entryErr
}
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
if storageErr != nil || entryErr != nil {
return fmt.Errorf("删除不完整(workspaceStorage: %d 处, %v;最近打开列表: %d 处, %v)",
storageRemoved, storageErr, entryRemoved, entryErr)
@@ -255,32 +263,40 @@ func removeRecentlyOpenedEntry(userDir string, projectPath string) (int, error)
}
return 0, fmt.Errorf("读取最近打开列表失败: %w", err)
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return 0, fmt.Errorf("解析最近打开列表失败: %w", err)
}
kept := make([]struct {
FolderURI string `json:"folderUri"`
}, 0, len(payload.Entries))
var entries []json.RawMessage
if err := json.Unmarshal(payload["entries"], &entries); err != nil {
return 0, err
}
kept := make([]json.RawMessage, 0, len(entries))
removed := 0
for _, item := range payload.Entries {
for _, entry := range entries {
var item struct {
FolderURI string `json:"folderUri"`
}
if err := json.Unmarshal(entry, &item); err != nil {
return 0, err
}
path := projectPathFromURI(item.FolderURI)
if path != "" && samePath(filepath.Clean(path), projectPath) {
removed++
continue
}
kept = append(kept, item)
kept = append(kept, entry)
}
if removed == 0 {
return 0, nil
}
updated, err := json.Marshal(map[string]any{"entries": kept})
payload["entries"], err = json.Marshal(kept)
if err != nil {
return 0, fmt.Errorf("序列化最近打开列表失败: %w", err)
return 0, err
}
updated, err := json.Marshal(payload)
if err != nil {
return 0, err
}
if _, err := db.Exec(
"UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'",

View File

@@ -186,4 +186,4 @@ func TestRemoveWorkspaceStorageEntries(t *testing.T) {
t.Fatalf("removed = %d, want 0", removed)
}
})
}
}

View File

@@ -0,0 +1,158 @@
package cursor
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"cursor/internal/appdata"
"cursor/internal/securefile"
)
var proxySettingsMu sync.Mutex
type proxySettingsBackup struct {
Original map[string]json.RawMessage `json:"original"`
Applied map[string]json.RawMessage `json:"applied"`
}
func settingsBackupPath() string {
return filepath.Join(appdata.DataRootPath(), "proxy-settings-backup.json")
}
func rawEqual(a, b json.RawMessage) bool {
var x, y bytes.Buffer
if json.Compact(&x, a) != nil || json.Compact(&y, b) != nil {
return len(a) == 0 && len(b) == 0
}
return bytes.Equal(x.Bytes(), y.Bytes())
}
func loadSettings(path string) (map[string]json.RawMessage, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return map[string]json.RawMessage{}, nil
}
if err != nil {
return nil, err
}
data, err = normalizeJSONC(data)
if err != nil {
return nil, err
}
if len(bytes.TrimSpace(data)) == 0 {
return map[string]json.RawMessage{}, nil
}
var settings map[string]json.RawMessage
if err = json.Unmarshal(data, &settings); err != nil {
return nil, err
}
if settings == nil {
return nil, fmt.Errorf("Cursor 配置必须是 JSON 对象")
}
return settings, nil
}
func readSettingsBackup() (proxySettingsBackup, error) {
backup := proxySettingsBackup{Original: map[string]json.RawMessage{}, Applied: map[string]json.RawMessage{}}
data, err := os.ReadFile(settingsBackupPath())
if errors.Is(err, os.ErrNotExist) {
return backup, nil
}
if err != nil {
return backup, err
}
err = json.Unmarshal(data, &backup)
if err == nil && (backup.Original == nil || backup.Applied == nil) {
err = fmt.Errorf("代理配置备份损坏")
}
return backup, err
}
func WriteUserProxySettings(proxyURL string) error {
if proxyURL == "" {
return fmt.Errorf("代理地址为空")
}
proxySettingsMu.Lock()
defer proxySettingsMu.Unlock()
path, err := resolveCursorSettingsPath()
if err != nil {
return err
}
settings, err := loadSettings(path)
if err != nil {
return fmt.Errorf("解析 Cursor 配置失败,原文件已保留: %w", err)
}
backup, err := readSettingsBackup()
if err != nil {
return err
}
values := map[string]any{"http.proxy": proxyURL, "http.proxyKerberosServicePrincipal": proxyURL, "http.proxySupport": "on", "cursor.general.disableHttp2": true, "http.experimental.systemCertificatesV2": true}
for key, value := range values {
if previous, exists := settings[key]; exists {
if !rawEqual(previous, backup.Applied[key]) {
backup.Original[key] = append(json.RawMessage(nil), previous...)
}
} else {
delete(backup.Original, key)
}
encoded, _ := json.Marshal(value)
backup.Applied[key] = encoded
settings[key] = encoded
}
backupData, err := json.Marshal(backup)
if err != nil {
return err
}
if err = securefile.Write(settingsBackupPath(), backupData); err != nil {
return err
}
// Preserve the product's intentional permanent auto-update setting.
settings["update.mode"] = json.RawMessage(`"none"`)
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
return securefile.Write(path, append(data, '\n'))
}
func ClearUserProxySettings() error {
proxySettingsMu.Lock()
defer proxySettingsMu.Unlock()
path, err := resolveCursorSettingsPath()
if err != nil {
return err
}
backup, err := readSettingsBackup()
if err != nil {
return err
}
if len(backup.Applied) == 0 {
return nil
} // No ownership proof: preserve user values.
settings, err := loadSettings(path)
if err != nil {
return fmt.Errorf("解析 Cursor 配置失败,原文件已保留: %w", err)
}
for key, applied := range backup.Applied {
if rawEqual(settings[key], applied) {
if previous, exists := backup.Original[key]; exists {
settings[key] = previous
} else {
delete(settings, key)
}
}
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
if err = securefile.Write(path, append(data, '\n')); err != nil {
return err
}
if err = os.Remove(settingsBackupPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View File

@@ -0,0 +1,167 @@
package cursor
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"cursor/internal/certs"
)
func isolatedHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, key := range []string{"HOME", "USERPROFILE", "APPDATA", "XDG_CONFIG_HOME"} {
t.Setenv(key, dir)
}
return dir
}
func TestProxySettingsRestoreAndPreserveConcurrentUserChange(t *testing.T) {
isolatedHome(t)
path, err := resolveCursorSettingsPath()
if err != nil {
t.Fatal(err)
}
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
original := []byte(`{"http.proxy":"http://original:8080","editor.fontSize":15,"custom":{"enabled":true}}`)
if err = os.WriteFile(path, original, 0600); err != nil {
t.Fatal(err)
}
for range 2 {
if err = WriteUserProxySettings("http://127.0.0.1:28080"); err != nil {
t.Fatal(err)
}
}
current, err := loadSettings(path)
if err != nil {
t.Fatal(err)
}
current["http.proxySupport"] = json.RawMessage(`"override"`)
data, _ := json.Marshal(current)
if err = os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
if err = ClearUserProxySettings(); err != nil {
t.Fatal(err)
}
got, err := loadSettings(path)
if err != nil {
t.Fatal(err)
}
if !rawEqual(got["http.proxy"], json.RawMessage(`"http://original:8080"`)) {
t.Fatal("original proxy not restored")
}
if !rawEqual(got["http.proxySupport"], json.RawMessage(`"override"`)) {
t.Fatal("user edit overwritten")
}
if !rawEqual(got["custom"], json.RawMessage(`{"enabled":true}`)) {
t.Fatal("unrelated settings lost")
}
if _, ok := got["cursor.general.disableHttp2"]; ok {
t.Fatal("injected key not removed")
}
if err = ClearUserProxySettings(); err != nil {
t.Fatal(err)
}
}
func TestInvalidSettingsNeverDeleted(t *testing.T) {
isolatedHome(t)
path, _ := resolveCursorSettingsPath()
_ = os.MkdirAll(filepath.Dir(path), 0700)
bad := []byte("{broken user settings // keep me")
if err := os.WriteFile(path, bad, 0600); err != nil {
t.Fatal(err)
}
if err := WriteUserProxySettings("http://127.0.0.1:28080"); err == nil {
t.Fatal("invalid settings accepted")
}
after, _ := os.ReadFile(path)
if !bytes.Equal(bad, after) {
t.Fatal("user settings destroyed")
}
}
func TestCertificateMigrationCancellationAndRetry(t *testing.T) {
newCert := []byte("local CA")
current, legacy := false, true
trusted := func(cert []byte) (bool, error) {
if bytes.Equal(cert, certs.EmbeddedCACertPEM()) {
return legacy, nil
}
return current, nil
}
denied := errors.New("authorization canceled")
if err := migrateCATrust(newCert, trusted, func() error { return denied }); !errors.Is(err, denied) {
t.Fatal(err)
}
if current || !legacy {
t.Fatal("cancellation changed trust")
}
if err := migrateCATrust(newCert, trusted, func() error { current = true; legacy = false; return nil }); err != nil {
t.Fatal(err)
}
if err := migrateCATrust(newCert, trusted, func() error { t.Fatal("migration not idempotent"); return nil }); err != nil {
t.Fatal(err)
}
legacy = true
if err := migrateCATrust(newCert, trusted, func() error { return nil }); err == nil {
t.Fatal("legacy trust silently retained")
}
}
func TestRecentlyOpenedPreservesUnknownFields(t *testing.T) {
dir := t.TempDir()
createStateDB(t, dir, []string{"file:///d:/Project/A", "file:///d:/Project/B"})
db, err := sql.Open("sqlite", filepath.Join(dir, "globalStorage", "state.vscdb"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
raw := `{"version":3,"metadata":{"keep":true},"entries":[{"folderUri":"file:///d:/Project/A","label":"remove"},{"folderUri":"file:///d:/Project/B","label":"keep","remoteAuthority":"ssh-remote+host"},{"fileUri":"file:///d:/readme.md","label":"file"}]}`
if _, err = db.Exec("UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'", raw); err != nil {
t.Fatal(err)
}
if n, err := removeRecentlyOpenedEntry(dir, "d:/Project/A"); err != nil || n != 1 {
t.Fatalf("removed=%d err=%v", n, err)
}
var updated string
if err = db.QueryRow("SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'").Scan(&updated); err != nil {
t.Fatal(err)
}
var got map[string]json.RawMessage
if err = json.Unmarshal([]byte(updated), &got); err != nil {
t.Fatal(err)
}
if !rawEqual(got["metadata"], json.RawMessage(`{"keep":true}`)) || !rawEqual(got["version"], json.RawMessage(`3`)) {
t.Fatal("top-level metadata lost")
}
var entries []map[string]any
_ = json.Unmarshal(got["entries"], &entries)
if len(entries) != 2 || entries[0]["remoteAuthority"] != "ssh-remote+host" || entries[1]["fileUri"] != "file:///d:/readme.md" {
t.Fatalf("unmatched entries changed: %s", updated)
}
}
func TestDatabaseFailurePreservesWorkspaceChatData(t *testing.T) {
dir := t.TempDir()
workspace := filepath.Join(dir, "workspaceStorage", "hash")
_ = os.MkdirAll(workspace, 0700)
_ = os.WriteFile(filepath.Join(workspace, "workspace.json"), []byte(`{"folder":"file:///d:/Project/A"}`), 0600)
_ = os.WriteFile(filepath.Join(workspace, "chat.db"), []byte("important user chat"), 0600)
_ = os.MkdirAll(filepath.Join(dir, "globalStorage"), 0700)
_ = os.WriteFile(filepath.Join(dir, "globalStorage", "state.vscdb"), []byte("corrupt database"), 0600)
if err := deleteProjectRecords(dir, "d:/Project/A"); err == nil {
t.Fatal("corrupt database accepted")
}
if data, err := os.ReadFile(filepath.Join(workspace, "chat.db")); err != nil || string(data) != "important user chat" {
t.Fatal("chat data removed before database update")
}
}

View File

@@ -30,12 +30,6 @@ var injectedCursorSettingsKeys = []string{
// EnsureCACertFile 用于处理与 EnsureCACertFile 相关的逻辑。
func EnsureCACertFile(certPEM []byte, currentPath string) (string, error) {
certPath := appdata.CACertFilePath()
if samePath(strings.TrimSpace(currentPath), certPath) {
if _, err := os.Stat(certPath); err == nil {
logger.Infof("ensureCACertFile: reusing path=%s", certPath)
return certPath, nil
}
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil {
return "", fmt.Errorf("创建证书配置目录失败: %w", err)
@@ -65,6 +59,9 @@ func samePath(left string, right string) bool {
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return false
}
if runtime.GOOS == "windows" {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
return filepath.Clean(left) == filepath.Clean(right)
}
@@ -117,125 +114,6 @@ func ClearSystemNodeExtraCACerts() error {
return nil
}
// WriteUserProxySettings 用于处理与 WriteUserProxySettings 相关的逻辑。
func WriteUserProxySettings(proxyURL string) error {
proxyURL = strings.TrimSpace(proxyURL)
if proxyURL == "" {
return errors.New("代理地址为空")
}
settingsPath, err := resolveCursorSettingsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return fmt.Errorf("创建 Cursor 配置目录失败: %w", err)
}
settings := make(map[string]any)
data, err := os.ReadFile(settingsPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
}
} else if len(bytes.TrimSpace(data)) > 0 {
parsed, err := decodeCursorSettingsJSONC(data)
if err != nil {
if removeErr := os.Remove(settingsPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
return fmt.Errorf("解析 Cursor 配置失败,且删除损坏配置失败: %w", removeErr)
}
logger.Infof("writeCursorUserProxySettings: removed invalid settings path=%s err=%v", settingsPath, err)
data = nil
} else {
settings = parsed
}
}
settings["http.proxy"] = proxyURL
settings["http.proxyKerberosServicePrincipal"] = proxyURL
settings["http.proxySupport"] = "on"
settings["cursor.general.disableHttp2"] = true
settings["http.experimental.systemCertificatesV2"] = true
// update.mode 写入后永久保留,不随 ClearUserProxySettings 清除,避免恢复自动更新。
settings["update.mode"] = "none"
encoded, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
}
encoded = append(encoded, '\n')
if len(bytes.TrimSpace(data)) > 0 && bytes.Equal(data, encoded) {
logger.Infof("writeCursorUserProxySettings: unchanged path=%s proxy=%s", settingsPath, proxyURL)
return nil
}
tempPath := settingsPath + ".tmp"
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
}
if err := os.Rename(tempPath, settingsPath); err != nil {
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
}
logger.Infof("writeCursorUserProxySettings: path=%s proxy=%s", settingsPath, proxyURL)
return nil
}
// ClearUserProxySettings 用于处理与 ClearUserProxySettings 相关的逻辑。
func ClearUserProxySettings() error {
settingsPath, err := resolveCursorSettingsPath()
if err != nil {
return err
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
}
if len(bytes.TrimSpace(data)) == 0 {
return nil
}
settings := make(map[string]any)
parsed, err := decodeCursorSettingsJSONC(data)
if err != nil {
return fmt.Errorf("解析 Cursor 配置失败: %w", err)
}
settings = parsed
changed := false
for _, key := range injectedCursorSettingsKeys {
if _, exists := settings[key]; exists {
delete(settings, key)
changed = true
}
}
if !changed {
return nil
}
encoded, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
}
encoded = append(encoded, '\n')
tempPath := settingsPath + ".tmp"
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
}
if err := os.Rename(tempPath, settingsPath); err != nil {
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
}
logger.Infof("clearCursorUserProxySettings: path=%s", settingsPath)
return nil
}
// resolveCursorSettingsPath 用于处理与 resolveCursorSettingsPath 相关的逻辑。
func resolveCursorSettingsPath() (string, error) {
homeDir, err := os.UserHomeDir()

View File

@@ -5,12 +5,16 @@ package cursor
import (
"crypto/sha1"
"crypto/x509"
"cursor/internal/certs"
"encoding/base64"
"encoding/binary"
"encoding/pem"
"errors"
"fmt"
"os/exec"
"strings"
"syscall"
"unicode/utf16"
"cursor/internal/logger"
)
@@ -147,16 +151,62 @@ func installCACertToWindowsStore(certPEM []byte, certPath string) error {
// EnsureCACertInstalled 确保证书已安装到 Windows 系统信任存储。
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("检查系统证书安装状态失败: %w", err)
}
if installed {
logger.Infof("ensureCACertInstalled: cert already installed in system store, skipping")
return migrateCATrust(certPEM, isCAInstalledAnyStore, func() error {
thumb, err := getCertThumbprint(certPEM)
if err != nil {
return err
}
legacy, err := getCertThumbprint(certs.EmbeddedCACertPEM())
if err != nil {
return err
}
body := windowsCAMigrationBody(certPath, thumb, legacy)
units := utf16.Encode([]rune(body))
bytes := make([]byte, len(units)*2)
for i, u := range units {
binary.LittleEndian.PutUint16(bytes[i*2:], u)
}
encoded := base64.StdEncoding.EncodeToString(bytes)
outer := "$p=Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','" + encoded + "') -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $p.ExitCode"
cmd := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", outer)
cmd.SysProcAttr = hideWindow()
if err := cmd.Run(); err != nil {
return fmt.Errorf("安装证书授权失败或已取消,请重试: %w", err)
}
cleanup := fmt.Sprintf("$ErrorActionPreference='Stop'; $s=New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','CurrentUser'); try { $s.Open('ReadWrite'); foreach($c in @($s.Certificates.Find('FindByThumbprint',%s,$false))) { $s.Remove($c) } } finally { $s.Close() }", quotePowerShellLiteral(legacy))
originalUser := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", cleanup)
originalUser.SysProcAttr = hideWindow()
if err := originalUser.Run(); err != nil {
return fmt.Errorf("移除当前用户的旧共享证书失败: %w", err)
}
return nil
}
logger.Infof("ensureCACertInstalled: cert not installed in system store, installing...")
return installCACertToWindowsStore(certPEM, certPath)
})
}
func isCAInstalledAnyStore(certPEM []byte) (bool, error) {
thumb, err := getCertThumbprint(certPEM)
if err != nil {
return false, err
}
script := "$ErrorActionPreference='Stop'; (Test-Path " + quotePowerShellLiteral(`Cert:\LocalMachine\Root\`+thumb) + ") -or (Test-Path " + quotePowerShellLiteral(`Cert:\CurrentUser\Root\`+thumb) + ")"
cmd := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = hideWindow()
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.EqualFold(strings.TrimSpace(string(out)), "True"), nil
}
func windowsCAMigrationBody(certPath, thumb, legacy string) string {
return fmt.Sprintf(`$ErrorActionPreference='Stop'
$c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(%s)
if ($c.Thumbprint -ne %s) { throw 'CA fingerprint mismatch' }
$s = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine')
try {
$s.Open('ReadWrite'); $s.Add($c)
if ($s.Certificates.Find('FindByThumbprint',%s,$false).Count -eq 0) { throw 'CA installation failed' }
foreach ($old in @($s.Certificates.Find('FindByThumbprint',%s,$false))) { $s.Remove($old) }
} finally { $s.Close() }
`, quotePowerShellLiteral(certPath), quotePowerShellLiteral(thumb), quotePowerShellLiteral(thumb), quotePowerShellLiteral(legacy))
}

View File

@@ -0,0 +1,46 @@
// Package localaccess prevents unrelated browser pages from driving local RPCs.
package localaccess
import (
"net"
"net/http"
"net/url"
"strings"
)
func AllowedOrigin(raw string) bool {
if raw == "" {
return true
} // Native Cursor/CLI requests do not send Origin.
u, err := url.Parse(raw)
if err != nil || u.User != nil || u.Hostname() == "" {
return false
}
switch strings.ToLower(u.Scheme) {
case "vscode-file", "vscode-webview", "wails":
return true
case "http", "https":
default:
return false
}
host := strings.ToLower(u.Hostname())
if host == "localhost" || host == "wails.localhost" || net.ParseIP(host).IsLoopback() {
return true
}
for _, domain := range []string{"cursor.sh", "cursor.com"} {
if host == domain || strings.HasSuffix(host, "."+domain) {
return u.Scheme == "https"
}
}
return false
}
func Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !AllowedOrigin(r.Header.Get("Origin")) {
http.Error(w, "untrusted browser origin", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,26 @@
package localaccess
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestNativeAndCursorOriginsAllowedUnrelatedPagesRejected(t *testing.T) {
for _, origin := range []string{"", "vscode-file://vscode-app", "vscode-webview://fixture", "wails://wails", "http://127.0.0.1:9245", "https://api2.cursor.sh", "https://www.cursor.com"} {
if !AllowedOrigin(origin) {
t.Errorf("rejected %q", origin)
}
}
for _, origin := range []string{"null", "https://evil.invalid", "https://api2.cursor.sh.evil.invalid", "https://api2.cursor.sh@evil.invalid", "file:///tmp/evil.html"} {
called := false
handler := Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
r := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/rpc", nil)
r.Header.Set("Origin", origin)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != 403 || called {
t.Errorf("origin %q reached backend", origin)
}
}
}

View File

@@ -14,6 +14,7 @@ import (
"time"
"cursor/internal/appdata"
"cursor/internal/securefile"
"github.com/lmittmann/tint"
"github.com/mattn/go-colorable"
@@ -50,7 +51,7 @@ func Init() {
if len(handlers) > 1 {
handler = &multiHandler{handlers: handlers}
}
slog.SetDefault(slog.New(handler))
slog.SetDefault(slog.New(redactingHandler{handler}))
stdlog.SetFlags(0)
if logFilePath != "" {
slog.Info("应用日志已写入文件", "path", logFilePath, "pid", os.Getpid())
@@ -180,10 +181,14 @@ func (writer *lineWindowFileWriter) Write(payload []byte) (int, error) {
}
func (writer *lineWindowFileWriter) openLocked() error {
file, err := os.OpenFile(writer.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
file, err := os.OpenFile(writer.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if err := securefile.Protect(writer.path); err != nil {
file.Close()
return err
}
writer.file = file
logFile = file
return nil
@@ -204,7 +209,7 @@ func (writer *lineWindowFileWriter) trimToLastLinesLocked(targetLines int) error
return err
}
trimmed, lineCount := lastLinesBytes(payload, targetLines)
if err := os.WriteFile(writer.path, trimmed, 0o644); err != nil {
if err := os.WriteFile(writer.path, trimmed, 0o600); err != nil {
if reopenErr := writer.openLocked(); reopenErr != nil {
return errors.Join(err, reopenErr)
}

85
internal/logger/redact.go Normal file
View File

@@ -0,0 +1,85 @@
package logger
import (
"context"
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"sync"
)
var secretRegistry = struct {
sync.RWMutex
values map[string]struct{}
}{values: map[string]struct{}{}}
var bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[^\s"'<>]+`)
var credentialPattern = regexp.MustCompile(`(?i)((?:api[_-]?key|token|authorization|cookie|password|secret)["']?\s*[:=]\s*["']?)[^"'\s,;}]+`)
func RegisterSecrets(values ...string) {
secretRegistry.Lock()
defer secretRegistry.Unlock()
for _, v := range values {
if v != "" {
secretRegistry.values[v] = struct{}{}
}
}
}
func redact(s string) string {
secretRegistry.RLock()
values := make([]string, 0, len(secretRegistry.values))
for value := range secretRegistry.values {
values = append(values, value)
}
secretRegistry.RUnlock()
sort.Slice(values, func(i, j int) bool { return len(values[i]) > len(values[j]) })
for _, value := range values {
s = strings.ReplaceAll(s, value, "[redacted]")
}
s = bearerPattern.ReplaceAllString(s, "Bearer [redacted]")
return credentialPattern.ReplaceAllString(s, "${1}[redacted]")
}
type redactingHandler struct{ next slog.Handler }
func (h redactingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.next.Enabled(ctx, level)
}
func redactAttr(a slog.Attr) slog.Attr {
key := strings.ToLower(a.Key)
for _, part := range []string{"apikey", "api_key", "token", "password", "secret", "authorization", "cookie", "payload", "headers", "body", "config"} {
if strings.Contains(key, part) {
return slog.String(a.Key, "[redacted]")
}
}
v := a.Value.Resolve()
switch v.Kind() {
case slog.KindString:
a.Value = slog.StringValue(redact(v.String()))
case slog.KindAny:
a.Value = slog.StringValue(redact(fmt.Sprint(v.Any())))
case slog.KindGroup:
attrs := v.Group()
for i := range attrs {
attrs[i] = redactAttr(attrs[i])
}
a.Value = slog.GroupValue(attrs...)
}
return a
}
func (h redactingHandler) Handle(ctx context.Context, r slog.Record) error {
next := slog.NewRecord(r.Time, r.Level, redact(r.Message), r.PC)
r.Attrs(func(a slog.Attr) bool { next.AddAttrs(redactAttr(a)); return true })
return h.next.Handle(ctx, next)
}
func (h redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
copy := append([]slog.Attr(nil), attrs...)
for i := range copy {
copy[i] = redactAttr(copy[i])
}
return redactingHandler{h.next.WithAttrs(copy)}
}
func (h redactingHandler) WithGroup(name string) slog.Handler {
return redactingHandler{h.next.WithGroup(name)}
}

View File

@@ -0,0 +1,21 @@
package logger
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
)
func TestSecretsRedactedInMessagesErrorsAttributesAndGroups(t *testing.T) {
RegisterSecrets("SENTINEL_PRIVATE_KEY")
var buffer bytes.Buffer
log := slog.New(redactingHandler{slog.NewJSONHandler(&buffer, nil)})
log.With("token", "BOUND_SECRET").Info("provider echoed SENTINEL_PRIVATE_KEY", "error", errors.New("Authorization: Bearer UNKNOWN_SECRET"), "payload", map[string]string{"config": "MAP_SECRET"}, "group", slog.GroupValue(slog.String("apiKey", "GROUP_SECRET")))
for _, secret := range []string{"SENTINEL_PRIVATE_KEY", "BOUND_SECRET", "UNKNOWN_SECRET", "MAP_SECRET", "GROUP_SECRET"} {
if strings.Contains(buffer.String(), secret) {
t.Fatalf("leaked secret %s", secret)
}
}
}

View File

@@ -18,6 +18,7 @@ import (
"time"
"cursor/internal/certs"
"cursor/internal/localaccess"
"cursor/internal/logger"
"cursor/internal/netproxy"
@@ -31,6 +32,7 @@ const (
// ProxyServer 定义了当前模块中的 ProxyServer 类型。
type ProxyServer struct {
lifecycleMu sync.Mutex
// addr 表示当前声明中的 addr。
addr string
// baseURL 表示当前声明中的 baseURL。
@@ -264,6 +266,8 @@ func (s *ProxyServer) ListenAndServe() error {
// Start 用于处理与 Start 相关的逻辑。
func (s *ProxyServer) Start() error {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.runMu.Lock()
defer s.runMu.Unlock()
if s.httpServer != nil {
@@ -308,15 +312,24 @@ func (s *ProxyServer) Start() error {
// Stop 用于处理与 Stop 相关的逻辑。
func (s *ProxyServer) Stop(ctx context.Context) error {
s.runMu.Lock()
httpServer := s.httpServer
if httpServer == nil {
s.runMu.Unlock()
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
s.runMu.RLock()
server := s.httpServer
s.runMu.RUnlock()
if server == nil {
return nil
}
s.httpServer = nil
err := server.Shutdown(ctx)
if err != nil {
_ = server.Close()
}
s.runMu.Lock()
if s.httpServer == server {
s.httpServer = nil
}
s.runMu.Unlock()
return httpServer.Shutdown(ctx)
return err
}
// IsRunning 用于处理与 IsRunning 相关的逻辑。
@@ -706,6 +719,9 @@ func shouldHandleLocalCORSPreflight(req *http.Request) bool {
// buildLocalCORSPreflightResponse 用于处理与 buildLocalCORSPreflightResponse 相关的逻辑。
func buildLocalCORSPreflightResponse(req *http.Request) *http.Response {
if req != nil && !localaccess.AllowedOrigin(req.Header.Get("Origin")) {
return &http.Response{StatusCode: http.StatusForbidden, Status: "403 Forbidden", Header: make(http.Header), Body: io.NopCloser(strings.NewReader("untrusted browser origin")), Request: req}
}
allowOrigin := "*"
if req != nil {
origin := strings.TrimSpace(req.Header.Get("Origin"))

View File

@@ -27,6 +27,9 @@ func NormalizeBaseURL(raw string) (string, error) {
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", fmt.Errorf("模型适配器 baseURL 仅支持 http 或 https")
}
if parsed.User != nil {
return "", fmt.Errorf("模型适配器 baseURL 不允许内嵌凭据,请使用访问密钥或自定义请求头")
}
if strings.TrimSpace(parsed.Host) == "" {
return "", fmt.Errorf("模型适配器 baseURL 缺少主机名")
}

View File

@@ -40,10 +40,10 @@ func InstallDefaultTransport() {
// NewHTTPClient creates an HTTP client that follows environment and OS proxy
// settings while preserving the caller's timeout choice.
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
return CredentialSafeClient(&http.Client{
Transport: NewTransport(nil),
Timeout: timeout,
}
})
}
// NewTransport clones the given transport and installs proxy resolution on it.

View File

@@ -0,0 +1,43 @@
package netproxy
import (
"fmt"
"net/http"
"net/url"
"strings"
)
func origin(u *url.URL) string {
port := u.Port()
if port == "" {
if strings.EqualFold(u.Scheme, "https") {
port = "443"
} else {
port = "80"
}
}
return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Hostname()) + ":" + port
}
// CredentialSafeClient retains client settings, while never forwarding custom
// authentication headers or POST bodies to a different origin via redirects.
func CredentialSafeClient(client *http.Client) *http.Client {
if client == nil {
client = http.DefaultClient
}
next := *client
previous := next.CheckRedirect
next.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && origin(req.URL) != origin(via[0].URL) {
return http.ErrUseLastResponse
}
if previous != nil {
return previous(req, via)
}
return nil
}
return &next
}

View File

@@ -0,0 +1,67 @@
package netproxy
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCrossOriginRedirectNeverReceivesCustomAuthentication(t *testing.T) {
called := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true }))
defer target.Close()
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
}))
defer redirect.Close()
client := CredentialSafeClient(redirect.Client())
r, _ := http.NewRequest(http.MethodPost, redirect.URL, strings.NewReader("private body"))
r.Header.Set("x-api-key", "sentinel")
r.Header.Set("Authorization", "Bearer sentinel")
resp, err := client.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if called || resp.StatusCode != 307 {
t.Fatal("credentials crossed origin")
}
}
func TestSameOriginRedirectAndCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/ok", 307)
return
}
if r.Header.Get("x-api-key") != "sentinel" {
t.Error("same-origin credential lost")
}
w.WriteHeader(204)
}))
defer server.Close()
client := CredentialSafeClient(server.Client())
r, _ := http.NewRequest(http.MethodGet, server.URL, nil)
r.Header.Set("x-api-key", "sentinel")
resp, err := client.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != 204 {
t.Fatal(resp.StatusCode)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
r, _ = http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if _, err = client.Do(r); err == nil {
t.Fatal("cancellation ignored")
}
client.Timeout = time.Nanosecond
if client.Timeout == 0 {
t.Fatal("timeout setting lost")
}
}

View File

@@ -0,0 +1,58 @@
// Package securefile provides private, durable files for application secrets.
package securefile
import (
"os"
"path/filepath"
)
func PrivateDir(path string) error {
if err := os.MkdirAll(path, 0700); err != nil {
return err
}
return protect(path, true)
}
func Protect(path string) error { return protect(path, false) }
func Write(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".local-cursor-*")
if err != nil {
return err
}
temp := f.Name()
defer os.Remove(temp)
if err = protect(temp, false); err == nil {
_, err = f.Write(data)
}
if err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
return os.Rename(temp, path)
}
// Lock is released by the OS when the process exits, including after a crash.
func Lock(path string) (func(), error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
if err = Protect(path); err == nil {
err = lockFile(f)
}
if err != nil {
f.Close()
return nil, err
}
return func() { unlockFile(f); f.Close() }, nil
}

View File

@@ -0,0 +1,17 @@
//go:build !windows
package securefile
import (
"golang.org/x/sys/unix"
"os"
)
func protect(path string, directory bool) error {
if directory {
return os.Chmod(path, 0700)
}
return os.Chmod(path, 0600)
}
func lockFile(f *os.File) error { return unix.Flock(int(f.Fd()), unix.LOCK_EX) }
func unlockFile(f *os.File) { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }

View File

@@ -0,0 +1,34 @@
//go:build windows
package securefile
import (
"golang.org/x/sys/windows"
"os"
)
func protect(path string, directory bool) error {
user, err := windows.GetCurrentProcessToken().GetTokenUser()
if err != nil {
return err
}
inherit := ""
if directory {
inherit = "OICI"
}
sd, err := windows.SecurityDescriptorFromString("D:P(A;" + inherit + ";FA;;;SY)(A;" + inherit + ";FA;;;" + user.User.Sid.String() + ")")
if err != nil {
return err
}
acl, _, err := sd.DACL()
if err != nil {
return err
}
return windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, nil, nil, acl, nil)
}
func lockFile(f *os.File) error {
return windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, &windows.Overlapped{})
}
func unlockFile(f *os.File) {
_ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, &windows.Overlapped{})
}

113
internal/skills/safety.go Normal file
View File

@@ -0,0 +1,113 @@
package skills
import (
"bytes"
"crypto/sha256"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"sync"
"cursor/internal/appdata"
"cursor/internal/securefile"
)
var operationMu sync.Mutex
func checkManagedPaths(project, name string) error {
paths := []string{filepath.Join(project, ".cursor"), skillsDir(project), skillTargetDir(project, name), projectCommandsDir(project)}
for _, file := range commandFileNames(name) {
paths = append(paths, filepath.Join(projectCommandsDir(project), file))
}
for _, entry := range paths {
info, err := os.Lstat(entry)
if os.IsNotExist(err) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("安全保护:拒绝通过符号链接修改 %s", entry)
}
}
target := skillTargetDir(project, name)
if _, err := os.Stat(target); os.IsNotExist(err) {
return nil
}
return filepath.WalkDir(target, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("安全保护:skill 内含符号链接 %s", p)
}
return nil
})
}
func backupBeforeReplace(target string, next []byte) error {
previous, err := os.ReadFile(target)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if bytes.Equal(previous, next) {
return nil
}
id := fmt.Sprintf("%x", sha256.Sum256([]byte(target)))
dir := filepath.Join(appdata.DataRootPath(), "skill-backups", id[:16])
if err = securefile.PrivateDir(dir); err != nil {
return err
}
file, err := os.CreateTemp(dir, filepath.Base(target)+"-*.bak")
if err != nil {
return err
}
name := file.Name()
if err = file.Close(); err != nil {
return err
}
return securefile.Write(name, previous)
}
func ensureNoCustomSkillFiles(project, name string) error {
allowed := map[string]bool{}
err := fs.WalkDir(builtinFS, path.Join(builtinRoot, name), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
rel := strings.TrimPrefix(p, path.Join(builtinRoot, name)+"/")
allowed[rel] = true
}
return nil
})
if err != nil {
return err
}
for _, file := range retiredSkillFiles[name] {
allowed[file] = true
}
target := skillTargetDir(project, name)
if _, err = os.Stat(target); os.IsNotExist(err) {
return nil
}
return filepath.WalkDir(target, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
rel, _ := filepath.Rel(target, p)
if !allowed[filepath.ToSlash(rel)] {
return fmt.Errorf("skill 含自建文件 %s,已保留;请先备份后手动移除", strings.TrimPrefix(p, target))
}
}
return nil
})
}

View File

@@ -0,0 +1,50 @@
package skills
import (
"os"
"path/filepath"
"testing"
)
func TestUninstallRejectsUnknownSkillAndPreservesCustomFiles(t *testing.T) {
dir := t.TempDir()
custom := filepath.Join(dir, ".cursor", "skills", "user-owned")
_ = os.MkdirAll(custom, 0700)
file := filepath.Join(custom, "SKILL.md")
_ = os.WriteFile(file, []byte("user data"), 0600)
if err := Uninstall(dir, "user-owned"); err == nil {
t.Fatal("unknown skill accepted")
}
if _, err := os.Stat(file); err != nil {
t.Fatal("custom skill deleted")
}
if err := Install(dir, "fd-engineering"); err != nil {
t.Fatal(err)
}
extra := filepath.Join(skillTargetDir(dir, "fd-engineering"), "my-notes.md")
_ = os.WriteFile(extra, []byte("notes"), 0600)
if err := Uninstall(dir, "fd-engineering"); err == nil {
t.Fatal("custom files silently deleted")
}
if _, err := os.Stat(extra); err != nil {
t.Fatal(err)
}
}
func TestUninstallPreservesModifiedCommand(t *testing.T) {
dir := t.TempDir()
if err := Install(dir, "fd-engineering"); err != nil {
t.Fatal(err)
}
command := filepath.Join(projectCommandsDir(dir), "FD-plan.md")
if err := os.WriteFile(command, []byte("custom command"), 0600); err != nil {
t.Fatal(err)
}
if err := Uninstall(dir, "fd-engineering"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(command)
if err != nil || string(data) != "custom command" {
t.Fatal("modified command deleted")
}
}

View File

@@ -4,6 +4,7 @@
package skills
import (
"bytes"
"embed"
"fmt"
"io/fs"
@@ -129,6 +130,16 @@ func Exists(name string) bool {
// 并将其 commands/ 模板分发到项目 .cursor/commands/。
// 目标已存在时同步为最新内置内容,并清理已退役的内置文件。
func Install(projectPath string, name string) error {
name = sanitizeSkillName(name)
projectPath = strings.TrimSpace(projectPath)
operationMu.Lock()
defer operationMu.Unlock()
if !Exists(name) {
return fmt.Errorf("未知的内置 skill")
}
if err := checkManagedPaths(projectPath, name); err != nil {
return err
}
name = sanitizeSkillName(name)
if name == "" || !Exists(name) {
return fmt.Errorf("内置 skill 不存在: %s", name)
@@ -170,6 +181,9 @@ func installCommands(projectPath string, name string) error {
if err != nil {
return fmt.Errorf("读取命令模板失败: %w", err)
}
if err := backupBeforeReplace(filepath.Join(commandsDir, fileName), data); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(commandsDir, fileName), data, 0o644); err != nil {
return fmt.Errorf("写入命令文件失败: %w", err)
}
@@ -179,6 +193,19 @@ func installCommands(projectPath string, name string) error {
// Uninstall 移除项目内的 skill 目录与随附的命令文件。不存在时幂等成功。
func Uninstall(projectPath string, name string) error {
name = sanitizeSkillName(name)
projectPath = strings.TrimSpace(projectPath)
operationMu.Lock()
defer operationMu.Unlock()
if !Exists(name) {
return fmt.Errorf("未知的内置 skill")
}
if err := checkManagedPaths(projectPath, name); err != nil {
return err
}
if err := ensureNoCustomSkillFiles(projectPath, name); err != nil {
return err
}
name = sanitizeSkillName(name)
if name == "" {
return fmt.Errorf("skill 名称不能为空")
@@ -196,6 +223,14 @@ func Uninstall(projectPath string, name string) error {
// 只删除内置清单中的命令文件,不碰用户自建命令。
commandsDir := projectCommandsDir(projectPath)
for _, fileName := range commandFileNames(name) {
expected, _ := builtinFS.ReadFile(path.Join(builtinRoot, name, commandsDirName, fileName))
current, readErr := os.ReadFile(filepath.Join(commandsDir, fileName))
if readErr == nil && !bytes.Equal(current, expected) {
continue
}
if readErr != nil && !os.IsNotExist(readErr) {
return readErr
}
if err := os.Remove(filepath.Join(commandsDir, fileName)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("移除命令文件失败: %w", err)
}
@@ -258,6 +293,9 @@ func copyEmbeddedDir(embedDir string, targetDir string, skipDir string) error {
if err != nil {
return err
}
if err := backupBeforeReplace(target, data); err != nil {
return err
}
return os.WriteFile(target, data, 0o644)
})
}