From 188225fae68ad4f70178644e84eac019e5c696f2 Mon Sep 17 00:00:00 2001 From: FuDie0915 Date: Tue, 10 Nov 2026 09:59:34 +0800 Subject: [PATCH] =?UTF-8?q?=C3=90=DE=B8=C2=B4=C8=AB=C3=8F=C3=AE=C4=BF?= =?UTF-8?q?=C3=89=C3=B3=C2=B2=C3=A9=C3=8E=C3=8A=C3=8C=E2=B2=A2=C2=B7=C2=A2?= =?UTF-8?q?=C2=B2=C2=BC=20v1.1.9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档 Ð޸´ Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£ Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£ Ñé֤£ºÁ½¸ö Go ģ¿éµIJâÊÔ/vet¡¢¹ؼüģ¿é race¡¢ǰ¶˲âÊÔ/Éú²ú¹¹½¨/Éó¼ơ¢ Go ¿ɴï©¶´ɨÃèÓë macOS/Linux ¿ÉÐÐģ¿齻²æ±àÒë¾ùͨ¹ý¡£ Windows ¸ôÀëÆô¶¯ͨ¹ý£»δÍê³ɵĵã»÷ðÑ̺ÍԭÉúƽ̨Ñé֤¼ûÉó²鱨¸档 --- .gitignore | 6 +- README.md | 7 +- Taskfile.yml | 6 +- build/config.yml | 4 +- build/darwin/Info.dev.plist | 4 +- build/darwin/Info.plist | 4 +- build/docker/Dockerfile.cross | 2 +- build/linux/nfpm/nfpm.yaml | 2 +- build/windows/Taskfile.yml | 2 +- build/windows/info.json | 8 +- build/windows/nsis/wails_tools.nsh | 2 +- build/windows/wails.exe.manifest | 4 +- cursor-tab-server/Dockerfile | 2 +- cursor-tab-server/README.md | 13 + cursor-tab-server/config.yaml | 2 + cursor-tab-server/go.mod | 4 +- cursor-tab-server/main.go | 83 ++- cursor-tab-server/main_test.go | 131 ++++ docs/architecture.md | 6 +- docs/build-environment.md | 53 +- docs/review-v1.1.9.md | 65 ++ frontend/package.json | 27 +- frontend/src/services/clientApi.js | 16 +- frontend/src/state/appState.js | 55 +- frontend/src/utils/configSafety.js | 21 + frontend/src/views/ModelEditor.vue | 23 +- frontend/test/appState.test.js | 32 + frontend/test/configSafety.test.js | 26 + frontend/vite.config.js | 4 +- frontend/yarn.lock | 624 ++++++++++-------- go.mod | 20 +- go.sum | 48 +- internal/app/runner.go | 30 +- internal/app/shortcut_windows.go | 5 +- internal/appdata/migrate.go | 11 +- internal/backend/README.md | 3 - .../agent/bridge/interaction/bridge.go | 7 +- .../agent/model/http_integration_test.go | 94 +++ .../backend/agent/model/request_override.go | 6 +- internal/backend/agent/model/retry.go | 3 +- internal/backend/forwarder/file_store.go | 11 +- .../forwarder/file_store_security_test.go | 20 + .../forwarder/usage_store_idempotency_test.go | 3 +- internal/backend/host.go | 97 +-- internal/backend/host_lifecycle_test.go | 105 +++ internal/backend/server/config/manager.go | 40 +- internal/backend/server/config/resolver.go | 4 +- internal/backend/server/config/revision.go | 33 + .../backend/server/config/revision_test.go | 83 +++ internal/backend/server/config/store.go | 51 +- internal/backend/server/config/types.go | 21 +- internal/backend/tab_forward_test.go | 65 ++ internal/certs/ca.go | 29 +- internal/certs/ca.key | 27 - internal/certs/local.go | 78 +++ internal/certs/local_test.go | 70 ++ internal/client/config.go | 12 +- internal/client/cursor.go | 28 +- internal/client/lifecycle.go | 120 ++-- internal/client/lifecycle_race_test.go | 33 + internal/client/models_fetch.go | 5 +- internal/client/models_fetch_test.go | 2 +- internal/client/observer.go | 4 +- internal/client/service.go | 52 +- internal/cursor/cert_migration.go | 37 ++ internal/cursor/cli.go | 158 ++--- internal/cursor/cli_preload.cjs | 43 ++ internal/cursor/cli_security_test.go | 89 +++ internal/cursor/darwin_cert.go | 29 +- internal/cursor/launcher_windows_test.go | 77 +++ internal/cursor/localize_other.go | 5 + internal/cursor/projects.go | 42 +- internal/cursor/projects_test.go | 2 +- internal/cursor/proxy_settings.go | 158 +++++ internal/cursor/security_regression_test.go | 167 +++++ internal/cursor/settings.go | 128 +--- internal/cursor/windows_cert.go | 72 +- internal/localaccess/origin.go | 46 ++ internal/localaccess/origin_test.go | 26 + internal/logger/logger.go | 11 +- internal/logger/redact.go | 85 +++ internal/logger/redact_test.go | 21 + internal/mitm/service.go | 28 +- internal/modelchannel/identity.go | 3 + internal/netproxy/netproxy.go | 4 +- internal/netproxy/redirect.go | 43 ++ internal/netproxy/redirect_test.go | 67 ++ internal/securefile/file.go | 58 ++ internal/securefile/file_unix.go | 17 + internal/securefile/file_windows.go | 34 + internal/skills/safety.go | 113 ++++ internal/skills/safety_test.go | 50 ++ internal/skills/skills.go | 38 ++ main.go | 19 +- release-notes.md | 14 +- scripts/release/main.go | 4 +- scripts/release/metadata.go | 65 ++ scripts/release/metadata_test.go | 43 ++ 98 files changed, 3324 insertions(+), 930 deletions(-) create mode 100644 cursor-tab-server/main_test.go create mode 100644 docs/review-v1.1.9.md create mode 100644 frontend/src/utils/configSafety.js create mode 100644 frontend/test/appState.test.js create mode 100644 frontend/test/configSafety.test.js create mode 100644 internal/backend/agent/model/http_integration_test.go create mode 100644 internal/backend/forwarder/file_store_security_test.go create mode 100644 internal/backend/host_lifecycle_test.go create mode 100644 internal/backend/server/config/revision.go create mode 100644 internal/backend/server/config/revision_test.go create mode 100644 internal/backend/tab_forward_test.go delete mode 100644 internal/certs/ca.key create mode 100644 internal/certs/local.go create mode 100644 internal/certs/local_test.go create mode 100644 internal/client/lifecycle_race_test.go create mode 100644 internal/cursor/cert_migration.go create mode 100644 internal/cursor/cli_preload.cjs create mode 100644 internal/cursor/cli_security_test.go create mode 100644 internal/cursor/launcher_windows_test.go create mode 100644 internal/cursor/proxy_settings.go create mode 100644 internal/cursor/security_regression_test.go create mode 100644 internal/localaccess/origin.go create mode 100644 internal/localaccess/origin_test.go create mode 100644 internal/logger/redact.go create mode 100644 internal/logger/redact_test.go create mode 100644 internal/netproxy/redirect.go create mode 100644 internal/netproxy/redirect_test.go create mode 100644 internal/securefile/file.go create mode 100644 internal/securefile/file_unix.go create mode 100644 internal/securefile/file_windows.go create mode 100644 internal/skills/safety.go create mode 100644 internal/skills/safety_test.go create mode 100644 scripts/release/metadata.go create mode 100644 scripts/release/metadata_test.go diff --git a/.gitignore b/.gitignore index caddafa..8170f41 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ cmd/ads-server/data/ cmd/ads-server/ads-server cmd/ads-server/ads-server-linux-amd64.tar cursor-tab-server/cursor-tab-server-linux-amd64.tar -build/build/ +build/build/ + +# Local certificate authorities must never be committed. +local-ca.pem +ca.key diff --git a/README.md b/README.md index 754eb21..a97e95d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ > 基于 [cursor-byok](https://github.com/leookun/cursor-byok.git) 衍生,感谢原作者。本项目仅供学习与个人使用,请支持正版 Cursor。 +下载:[最新公开发布](https://stupidbrain.sbs/FuDie0915/Local-Cursor/releases/latest);[v1.1.9 审查与升级说明](docs/review-v1.1.9.md)。 + ## 功能 | 功能 | 说明 | @@ -118,7 +120,7 @@ tabServerToken: "" ### 前置依赖 -- Go 1.25+ +- Go 1.26.10(项目已固定工具链,语言基线 1.26.0) - Node.js 20+(含 yarn,可用 corepack) - [Wails v3 CLI](https://wails.io/):`go install github.com/wailsapp/wails/v3/cmd/wails3@latest` - protoc + protoc-gen-go + protoc-gen-connect-go(生成 `gen/`,运行 `task sync:proto` 或手动执行 `build/Taskfile.yml` 中的 generate:proto) @@ -147,7 +149,8 @@ task dev ~/.cursor-local-assistant-v2/ ├── config.yaml # 用户配置 ├── data/ -│ ├── ca.crt # 注入 CA 证书 +│ ├── ca.crt # 注入本机 CA 证书 +│ ├── local-ca.pem # 本机私有 CA(不得共享或提交) │ ├── codebase-index/ # 代码库索引 │ └── docs-index/ # 文档索引 ├── history/ diff --git a/Taskfile.yml b/Taskfile.yml index 434848c..5aa36f8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -281,7 +281,7 @@ tasks: msg: "未检测到 wails3,请先安装 Wails v3 CLI。" cmds: - mkdir -p "{{.WINDOWS_RELEASE_DIR}}" - - task: common:update:build-assets + - cmd: go run ./scripts/release sync-metadata - task: windows:create:zip vars: ARCH: amd64 @@ -307,7 +307,7 @@ tasks: msg: "未检测到 wails3,请先安装 Wails v3 CLI。" cmds: - task: release:clean - - task: common:update:build-assets + - cmd: go run ./scripts/release sync-metadata - task: release:notes - task: release:build:macos:arm64 - task: release:build:macos:amd64 @@ -323,7 +323,7 @@ tasks: msg: "未检测到 wails3,请先安装 Wails v3 CLI。" cmds: - task: release:ensure:dir - - task: common:update:build-assets + - cmd: go run ./scripts/release sync-metadata - task: release:notes - task: release:build:linux:amd64 diff --git a/build/config.yml b/build/config.yml index 88f5c83..fa9c7d9 100644 --- a/build/config.yml +++ b/build/config.yml @@ -1,6 +1,6 @@ version: "3" -# 构建元信息(修改后可执行:wails3 task common:update:build-assets) +# 构建元信息(修改版本后执行:go run ./scripts/release sync-metadata) info: companyName: "Local-Cursor" productName: "Local-Cursor" @@ -8,7 +8,7 @@ info: description: "Local-Cursor" copyright: "© 2026, Local-Cursor" comments: "Local-Cursor" - version: "1.1.8" + version: "1.1.9" dev_mode: root_path: . diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 5c9ea64..b8d9157 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.8 + 1.1.9 CFBundleVersion - 1.1.8 + 1.1.9 LSMinimumSystemVersion 10.15.0 LSUIElement diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index b104ecd..0b2a241 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.8 + 1.1.9 CFBundleVersion - 1.1.8 + 1.1.9 LSMinimumSystemVersion 10.15.0 LSUIElement diff --git a/build/docker/Dockerfile.cross b/build/docker/Dockerfile.cross index fbe649c..5b8202f 100644 --- a/build/docker/Dockerfile.cross +++ b/build/docker/Dockerfile.cross @@ -3,7 +3,7 @@ ARG BASE_IMAGE=debian:trixie FROM ${BASE_IMAGE} -ARG GO_TARBALL_URL=https://go.dev/dl/go1.25.0.linux-amd64.tar.gz +ARG GO_TARBALL_URL=https://go.dev/dl/go1.26.10.linux-amd64.tar.gz RUN apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ diff --git a/build/linux/nfpm/nfpm.yaml b/build/linux/nfpm/nfpm.yaml index 4d91377..72ec493 100644 --- a/build/linux/nfpm/nfpm.yaml +++ b/build/linux/nfpm/nfpm.yaml @@ -6,7 +6,7 @@ name: "Local-Cursor" arch: ${GOARCH} platform: "linux" -version: "1.1.8" +version: "1.1.9" section: "default" priority: "extra" maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml index ba3edad..42d2f9b 100644 --- a/build/windows/Taskfile.yml +++ b/build/windows/Taskfile.yml @@ -126,7 +126,7 @@ tasks: USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' create:zip: - summary: 生成 Windows ZIP 包(包含 exe 与 certs) + summary: 生成 Windows ZIP 包(仅含便携程序) internal: true vars: ARCH: '{{.ARCH | default ARCH}}' diff --git a/build/windows/info.json b/build/windows/info.json index 80849af..c784bbd 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,12 +1,12 @@ { "fixed": { - "file_version": "1.1.8", - "product_version": "1.1.8" + "file_version": "1.1.9", + "product_version": "1.1.9" }, "info": { "0409": { - "FileVersion": "1.1.8", - "ProductVersion": "1.1.8", + "FileVersion": "1.1.9", + "ProductVersion": "1.1.9", "CompanyName": "Local-Cursor", "FileDescription": "Local-Cursor", "LegalCopyright": "© 2026, Local-Cursor", diff --git a/build/windows/nsis/wails_tools.nsh b/build/windows/nsis/wails_tools.nsh index c116d08..d5d38c6 100644 --- a/build/windows/nsis/wails_tools.nsh +++ b/build/windows/nsis/wails_tools.nsh @@ -14,7 +14,7 @@ !define INFO_PRODUCTNAME "Local-Cursor" !endif !ifndef INFO_PRODUCTVERSION - !define INFO_PRODUCTVERSION "1.1.8" + !define INFO_PRODUCTVERSION "1.1.9" !endif !ifndef INFO_COPYRIGHT !define INFO_COPYRIGHT "© 2026, Local-Cursor" diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest index 1a5561e..72c3edf 100644 --- a/build/windows/wails.exe.manifest +++ b/build/windows/wails.exe.manifest @@ -1,9 +1,9 @@ - + - + diff --git a/cursor-tab-server/Dockerfile b/cursor-tab-server/Dockerfile index e1cb05e..12810d6 100644 --- a/cursor-tab-server/Dockerfile +++ b/cursor-tab-server/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 AS build +FROM golang:1.26.10 AS build WORKDIR /src diff --git a/cursor-tab-server/README.md b/cursor-tab-server/README.md index 262c7f6..ca1e64d 100644 --- a/cursor-tab-server/README.md +++ b/cursor-tab-server/README.md @@ -1,3 +1,16 @@ +# 独立 Tab 补全服务 + +服务监听 18041,支持远程部署。配置文件必须包含两种不同的令牌: + +```yaml +token: "你的上游 Cursor 访问令牌" +clientToken: "另行生成的随机客户端访问密钥" +``` + +调用方使用 `Authorization: Bearer `,Local-Cursor 中对应 `tabServerToken`。服务验证客户端后,用上游 token 转发请求。不要共享上游令牌作为客户端密钥;对外部署请通过 HTTPS 反向代理提供服务。 + +仅接受 POST 和已配置的 RPC 路由,单次请求最大 64 MiB。缺少或错误的客户端凭据返回 401。配置或令牌不会写入日志。 + ### 如何获取 token? **macos** ```bash diff --git a/cursor-tab-server/config.yaml b/cursor-tab-server/config.yaml index ecefbc3..d287079 100644 --- a/cursor-tab-server/config.yaml +++ b/cursor-tab-server/config.yaml @@ -1 +1,3 @@ token: "eyJ..." +# Separate random caller secret; configure the same value as tabServerToken. +clientToken: "" diff --git a/cursor-tab-server/go.mod b/cursor-tab-server/go.mod index b739c92..6f74e06 100644 --- a/cursor-tab-server/go.mod +++ b/cursor-tab-server/go.mod @@ -1,5 +1,7 @@ module cursor-tab-server -go 1.25 +go 1.26.0 + +toolchain go1.26.10 require gopkg.in/yaml.v3 v3.0.1 diff --git a/cursor-tab-server/main.go b/cursor-tab-server/main.go index 3038db0..e4fca36 100644 --- a/cursor-tab-server/main.go +++ b/cursor-tab-server/main.go @@ -2,7 +2,9 @@ package main import ( "bytes" + "context" "crypto/sha256" + "crypto/subtle" "encoding/base64" "errors" "fmt" @@ -58,7 +60,8 @@ var defaultUpstreamTargets = map[string]string{ } type appConfig struct { - Token string + Token string `yaml:"token"` + ClientToken string `yaml:"clientToken"` } type serverApp struct { @@ -97,9 +100,65 @@ func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string return app } +const maxRequestBytes int64 = 64 << 20 + +type trackingWriter struct { + http.ResponseWriter + started bool +} + +func (w *trackingWriter) WriteHeader(code int) { + if !w.started { + w.started = true + w.ResponseWriter.WriteHeader(code) + } +} +func (w *trackingWriter) Write(p []byte) (int, error) { + if !w.started { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(p) +} +func (w *trackingWriter) Flush() { + if !w.started { + w.WriteHeader(http.StatusOK) + } + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) { - if err := app.handleProxy(writer, request); err != nil { - http.Error(writer, err.Error(), http.StatusBadGateway) + w := &trackingWriter{ResponseWriter: writer} + expected := formatBearerAuthorization(app.config.ClientToken) + if app.config.ClientToken == "" || subtle.ConstantTimeCompare([]byte(request.Header.Get("Authorization")), []byte(expected)) != 1 { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if request.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if request.ContentLength > maxRequestBytes { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + request.Body = http.MaxBytesReader(w, request.Body, maxRequestBytes) + if err := app.handleProxy(w, request); err != nil { + if !w.started { + status := http.StatusBadGateway + var limit *http.MaxBytesError + if errors.As(err, &limit) { + status = http.StatusRequestEntityTooLarge + } + if errors.Is(err, context.Canceled) { + status = 499 + } + http.Error(w, http.StatusText(status), status) + } else { + log.Printf("流式转发中断 path=%s", request.URL.Path) + } } } @@ -160,11 +219,19 @@ func loadConfig(path string) (appConfig, error) { if err != nil { return appConfig{}, err } - token, err := parseTokenYAML(contents) - if err != nil { + var cfg appConfig + if err = yaml.Unmarshal(contents, &cfg); err != nil { return appConfig{}, err } - return appConfig{Token: token}, nil + cfg.Token = strings.TrimSpace(cfg.Token) + cfg.ClientToken = strings.TrimSpace(cfg.ClientToken) + if cfg.Token == "" || cfg.ClientToken == "" { + return appConfig{}, fmt.Errorf("token 和独立 clientToken 均不能为空") + } + if cfg.Token == cfg.ClientToken { + return appConfig{}, fmt.Errorf("clientToken 必须与上游 token 不同") + } + return cfg, nil } func parseTokenYAML(contents []byte) (string, error) { @@ -277,7 +344,9 @@ func buildCursorChecksum(authorization string) string { } func newHTTPClient() *http.Client { - return &http.Client{} + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.ResponseHeaderTimeout = 30 * time.Second + return &http.Client{Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} } func cloneUpstreamTargets(input map[string]string) map[string]string { diff --git a/cursor-tab-server/main_test.go b/cursor-tab-server/main_test.go new file mode 100644 index 0000000..dc88aaf --- /dev/null +++ b/cursor-tab-server/main_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAuthorizationAndUpstreamTokenSeparation(t *testing.T) { + calls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer upstream-secret" { + t.Error("wrong upstream token") + } + _, _ = w.Write([]byte("ok")) + })) + defer upstream.Close() + app := newServerApp(appConfig{Token: "upstream-secret", ClientToken: "client-secret"}, upstream.Client(), map[string]string{"/rpc": upstream.URL}) + for _, token := range []string{"", "Bearer wrong", "Bearer upstream-secret", "Bearer client-secret"} { + r := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader("request")) + r.Header.Set("Authorization", token) + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + want := 401 + if token == "Bearer client-secret" { + want = 200 + } + if w.Code != want { + t.Fatalf("status=%d want=%d", w.Code, want) + } + } + if calls != 1 { + t.Fatal("unauthorized request reached upstream") + } +} + +func TestOversizeRejectedBeforeUpstream(t *testing.T) { + app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, nil, map[string]string{"/rpc": "http://127.0.0.1:1"}) + r := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader("")) + r.ContentLength = maxRequestBytes + 1 + r.Header.Set("Authorization", "Bearer client") + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + if w.Code != 413 { + t.Fatalf("status=%d", w.Code) + } +} + +type testTransport func(*http.Request) (*http.Response, error) + +func (t testTransport) RoundTrip(r *http.Request) (*http.Response, error) { return t(r) } + +type brokenStream struct{ sent bool } + +func (b *brokenStream) Read(p []byte) (int, error) { + if !b.sent { + b.sent = true + return copy(p, []byte("partial-event")), nil + } + return 0, errors.New("stream interrupted") +} +func (b *brokenStream) Close() error { return nil } + +func TestStreamErrorNeverAppendsHTTPError(t *testing.T) { + client := &http.Client{Transport: testTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: &brokenStream{}}, nil + })} + app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, client, map[string]string{"/rpc": "https://upstream.invalid"}) + r := httptest.NewRequest(http.MethodPost, "/rpc", nil) + r.Header.Set("Authorization", "Bearer client") + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + if w.Code != 200 || w.Body.String() != "partial-event" { + t.Fatalf("response corrupted: %d %q", w.Code, w.Body.String()) + } +} + +func TestCanceledRequestDoesNotCallUpstream(t *testing.T) { + client := &http.Client{Transport: testTransport(func(r *http.Request) (*http.Response, error) { return nil, r.Context().Err() })} + app := newServerApp(appConfig{Token: "upstream", ClientToken: "client"}, client, map[string]string{"/rpc": "https://upstream.invalid"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r := httptest.NewRequest(http.MethodPost, "/rpc", nil).WithContext(ctx) + r.Header.Set("Authorization", "Bearer client") + w := httptest.NewRecorder() + app.ServeHTTP(w, r) + if w.Code != 499 { + t.Fatalf("status=%d", w.Code) + } +} + +func TestClientTokenRequiredAndDistinct(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.yaml") + for _, content := range []string{"token: upstream\n", "token: same\nclientToken: same\n"} { + if err := os.WriteFile(p, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, err := loadConfig(p); err == nil { + t.Fatal("insecure configuration accepted") + } + } + _ = os.WriteFile(p, []byte("token: upstream\nclientToken: client\n"), 0600) + if _, err := loadConfig(p); err != nil { + t.Fatal(err) + } +} + +func TestUpstreamRedirectDoesNotForwardSecret(t *testing.T) { + leaked := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true; _, _ = io.WriteString(w, "bad") })) + defer target.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 307) })) + defer redirect.Close() + r, _ := http.NewRequest(http.MethodPost, redirect.URL, strings.NewReader("")) + r.Header.Set("Authorization", "Bearer secret") + resp, err := newHTTPClient().Do(r) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 307 || leaked { + t.Fatal("redirect followed with credentials") + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 56f861c..764a88f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## 目录布局 ``` -Local-Curosr/ +Local-Cursor/ ├── cmd/ # 独立可执行工具入口 │ └── ext-tool/ # proto 提取工具,从 Cursor 扩展源码生成 .proto 定义 ├── internal/ # 内部业务包(不对外暴露) @@ -39,6 +39,6 @@ Local-Curosr/ ## 数据流 ``` -Cursor IDE → MITM(38081) → backend(39091) → 路由 → forwarder → agent/model → 上游模型 -CLI agent → tls-bypass → HTTPS(39092) → 同一 backend mux → 同上 +Cursor IDE → MITM(28080) → backend(28090) → 路由 → forwarder → agent/model → 上游模型 +CLI agent → TLS redirect → HTTPS(39092) → 同一 backend mux → 同上 ``` \ No newline at end of file diff --git a/docs/build-environment.md b/docs/build-environment.md index 99b5235..5a2cc86 100644 --- a/docs/build-environment.md +++ b/docs/build-environment.md @@ -1,41 +1,30 @@ -# Windows 构建环境 +# 构建与发布环境 -以下路径于 2026-11-03 在当前机器核实。项目发布不依赖额外安装 NSIS;使用下方已缓存的可执行文件。 - -| 工具 | 实际路径 | -|---|---| -| Git | `D:\WorkProgarm\DevTools\Git\cmd\git.exe` | -| Go | `D:\WorkProgarm\DevTools\go\bin\go.exe` | -| Wails v3 | `D:\WorkProgarm\DevCache\go-path\bin\wails3.exe` | -| Task | `C:\Users\FuDie915\AppData\Local\Microsoft\WinGet\Links\task.exe` | -| Node.js | `C:\Users\FuDie915\AppData\Local\fnm_multishells\14812_1793715538718\node.exe` | -| Yarn | `C:\Users\FuDie915\AppData\Local\fnm_multishells\14812_1793715538718\yarn.ps1` | -| protoc | `C:\Users\FuDie915\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe` | -| protoc-gen-go | `D:\WorkProgarm\DevCache\go-path\bin\protoc-gen-go.exe` | -| protoc-gen-connect-go | `D:\WorkProgarm\DevCache\go-path\bin\protoc-gen-connect-go.exe` | -| NSIS | `C:\Users\FuDie915\AppData\Local\electron-builder\Cache\nsis\nsis-3.0.4.1\Bin\makensis.exe` | -| PowerShell / Compress-Archive | `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` | - -Node.js 和 Yarn 路径由 fnm 按会话生成,重新打开终端后应以 `Get-Command node, yarn` 的实际输出为准。NSIS 已验证可执行,版本为 v3.04。 - -## 构建前设置 - -NSIS 当前未加入全局 `PATH`,在当前 PowerShell 会话中加入后即可使用标准发布任务: +工具路径以当前会话为准,不依赖旧的 fnm 会话目录: ```powershell -$env:Path = "C:\Users\FuDie915\AppData\Local\electron-builder\Cache\nsis\nsis-3.0.4.1\Bin;$env:Path" -task release:prepare +Get-Command git, go, node, yarn, wails3, task, 7z +go version +go env GOROOT ``` -## 标准 Windows 输出 +- Go 使用项目固定的 1.26.10 工具链;Node.js 需满足 Vite 的引擎要求(本次验证为 Node 24)。 +- 前端依赖通过 yarn.lock 和 security resolutions 固定,使用 `yarn --cwd frontend install --frozen-lockfile`。 +- 修改 build/config.yml 版本后执行 `go run ./scripts/release sync-metadata`,仅同步版本字段,保留定制构建任务。 +- `task release:prepare` 会构建平台资源;本次 Gitea 发布只上传压缩后的 Windows x64 便携 ZIP,避免同时保存 EXE、安装包和重复 ZIP。 +- `bin/`、生成的 bindings/proto、node_modules 和运行数据不提交到 Git。 -以 v1.1.8 为例,产物写入 `bin/release/v1.1.8/`: +验证命令: -```text -Local-Cursor.exe -Local-Cursor-amd64-installer.exe -local-cursor-v1.1.8-installer.zip -local-cursor-v1.1.8-windows-x64-portable.zip +```powershell +go test ./... +go vet ./... +go test -race ./internal/backend/... ./internal/client/... ./internal/certs/... ./internal/skills/... +govulncheck ./... +yarn --cwd frontend test +yarn --cwd frontend audit --summary +yarn --cwd frontend build ``` -其中 portable ZIP 内的程序名为 `windows-64.exe`,installer ZIP 内的安装包名为 `Local-Cursor-amd64-installer.exe`。 +Tab 服务是独立 Go 模块,需在 cursor-tab-server 下单独运行测试和扫描。 +实际数据修改、证书迁移测试使用临时用户目录和模拟信任存储。Windows UI 诊断可使用 `Local-Cursor.exe --no-auto-start`;该选项不自动启用代理、安装根证书或注册快捷方式。 diff --git a/docs/review-v1.1.9.md b/docs/review-v1.1.9.md new file mode 100644 index 0000000..2dcd232 --- /dev/null +++ b/docs/review-v1.1.9.md @@ -0,0 +1,65 @@ +# v1.1.9 项目审查 + +审查日期:2026-11-10。范围包括应用与 Wails 桥接、配置与前端、Cursor/CLI 集成、代理与 HTTP 服务、模型适配和流式协议、会话与用量存储、Skills、独立 Tab 服务、构建及发布脚本。 + +## 修复清单 + +| 类别 | 问题与处理 | 回归依据 | +|---|---|---| +| 证书 | 从运行代码和当前源码树移除共享 CA 私钥;每个本机用户数据目录持久保存独立私有 CA,私钥权限受保护,损坏时拒绝静默轮换 | 独立性、复用、损坏文件和密钥匹配测试 | +| 证书迁移 | 安装并验证新 CA 后按旧证书指纹清理信任;管理员与当前用户存储分别处理,取消或失败阻止启动 | 取消、重试、幂等和迁移脚本语法测试 | +| CLI | 恢复 TLS 验证,仅重定向 Cursor API,保留 SNI;兼容中文、空格、引号路径和原有附加 CA;移除继承的旧 bypass 配置 | Node HTTP/2、错误 CA、无效主机名及 Windows 启动器实测 | +| 凭据 | 前端日志不输出配置正文;旧敏感缓存迁移为偏好白名单;后端统一脱敏并保护日志文件 | 日志和缓存哨兵测试 | +| 配置 | 补齐 CLI HTTPS 端口的界面往返;通信 revision 做原子比较,冲突保留草稿;后端模型使用记录不被界面回写覆盖 | 后端 CAS、并发竞争及真实前端状态模块测试 | +| Cursor 数据 | 损坏设置原样保留;注入前备份受影响键,停止时只恢复仍由本应用占用的键 | 损坏文件、重复注入、用户并发修改测试 | +| 最近项目 | 保留顶层元数据、文件/工作区条目及未知字段;数据库更新失败时不删除聊天目录 | 数据库与 workspaceStorage 临时夹具测试 | +| 会话存储 | 拒绝点目录、分隔符、控制字符、NTFS 流及 Windows 保留名称 | 跨平台路径案例测试 | +| 生命周期 | HTTP/HTTPS 启动失败回滚;关闭超时后关闭连接、释放端口;串行化启停,保护初始化期间状态读取 | 端口重绑定、连接取消和 race 测试 | +| 协议 | 修复两处 Protobuf 锁复制;完整传递模型自定义头、Anthropic 扩展参数;处理特殊 Host 头 | go vet、模型本地 HTTP/SSE 集成测试 | +| 用量 | 测试夹具改用独立 filler ID,保留跨 500 个事件的去重验证 | 原失败测试恢复通过 | +| Tab 服务 | 客户端令牌与上游令牌分离;401、64 MiB 请求上限、取消和流式错误收口;配置变更与 URL 前缀正确生效 | 独立模块与应用转发集成测试 | +| 浏览器访问 | 本地 RPC/CORS 拒绝无关网站 Origin,允许原生请求和 Cursor/Wails 来源 | Origin 欺骗及本地请求测试 | +| 重定向 | 认证请求不向其他 origin 转发头和正文,保留同源重定向及取消语义 | 本地双服务器测试 | +| Skills | 拒绝卸载未知技能及穿过符号链接修改;保留自建文件、修改过的命令;覆盖前在本机私有数据目录备份 | 自建技能、命令保留及现有安装/刷新测试 | +| Windows 启动 | 快捷方式路径转义;增加 `--version` 和 `--no-auto-start`,支持隔离的只打开界面诊断 | 版本命令、隔离启动检查 | +| 构建 | 版本资源同步只修改版本字段,避免发布时重新生成并覆盖定制 Wails 模板 | 元信息同步和自定义构建文件保留测试 | + +## 依赖与兼容 + +- Go 工具链固定为 1.26.10,语言基线 1.26.0;升级 x/net、x/text、go-git、go-billy 及必要传递依赖。 +- Vue、Vite、PostCSS 及存在安全公告的传递依赖升级到修复版本,Wails JS runtime 固定为原已使用的 3.0.0-alpha.79。 +- `uuid` 对 vite-plugin-top-level-await 的传递依赖覆盖为兼容其 v4/v5 调用 API 的 11.1.1;生产构建验证该兼容性。 +- 修复版 esbuild 无法生成原 Safari 13 目标的解构语法降级,前端构建基线调整为 Safari 14.1。Windows WebView2 不受影响;本版不承诺旧版 Safari 内核。 + +## 验证记录 + +| 检查 | 结果 | +|---|---| +| 根 Go 模块 | `go test ./...` 与 `go vet ./...` 均通过 | +| 独立 Tab 模块 | 测试、`go vet` 与 `govulncheck` 均通过 | +| 并发检查 | 配置、backend、client、certs、skills 的 `go test -race` 通过,含启动状态轮询与关闭超时 | +| Go 漏洞扫描 | 0 条可达漏洞、0 条导入包漏洞;根模块有 4 条所需模块中未被本项目调用的公告,不能视为所有传递模块均无公告 | +| 前端 | 4 项 Node 测试通过;生产构建通过;Yarn audit 成功完成,286 项依赖、0 条漏洞 | +| macOS/Linux | `CGO_ENABLED=0`、amd64 下 certs、cursor、skills、netproxy、backend 模块编译通过;补齐仅 Windows 使用的安装路径扫描函数在其他平台的空实现 | +| Windows 启动 | 最终二进制以 `--no-auto-start` 在临时用户目录启动,窗口正常响应,WebView2 初始化成功,复用该隔离目录的本机 CA | +| 二进制 | Go 1.26.10、windows/amd64、production、CGO 关闭;`--version`、PE FileVersion 与 ProductVersion 均为 1.1.9 | +| 便携包 | ZIP 完整性检查通过,仅含 `windows-64.exe`;压缩包 19,780,599 字节,程序 53,621,248 字节 | + +便携 ZIP:`local-cursor-v1.1.9-windows-x64-portable.zip`。SHA-256: + +```text +8b7898f139983a7caad0bef45ae52d9c0a4789ebb41771682be92d455fb4e150 +``` + +重点回归包含私有 CA/Node TLS/HTTP2、Windows PowerShell 路径和参数、多窗口保存竞争、异常关闭后端口重绑定、模型认证/SSE/取消、Tab 鉴权/超限/转发、未知数据字段保留、真实前端状态模块与凭据缓存。生产构建存在单个 JS chunk 超过 500 kB 的体积提示,不影响构建成功。 + +平台限制:本机为 Windows。Windows 窗口点击冒烟因 Computer Use 本地管道不可用未完成;已完成进程响应、日志和 WebView2 初始化检查,不能据此声称已人工确认所有界面交互。macOS/Linux 未运行原生 GUI、完整桌面打包或真实证书存储操作。自动化证书迁移测试使用模拟信任存储和脚本解析,不向本机系统信任库安装测试 CA;涉及 Cursor 数据的破坏性测试使用临时目录与测试数据库。 + +## 升级说明 + +1. 首次启动需要信任本机新证书并移除旧共享根证书,Windows 可能要求一次管理员授权。失败时可重试,模型配置和历史数据继续保留。 +2. 本机 CA 私有文件为用户数据目录下的 `data/local-ca.pem`。不要将它放入仓库、发布包或共享给其他机器。旧私钥已经公开,删除旧发布页不能撤回公开过的内容;升级以移除旧根信任为准。 +3. 远程 Tab 服务新增必填 `clientToken`,必须与上游 `token` 不同;应用配置的 `tabServerToken` 填对应客户端令牌。本机未启用 Tab 服务的用户不需要修改。 +4. 被覆盖的 Skills 文件备份保存在本机数据目录的 `data/skill-backups/`;含自建文件的技能不会被无提示整体删除。 +5. 多窗口配置冲突时保留编辑草稿,核对并刷新版本后重新保存。若目标模型已被其他窗口修改或删除,则不会把草稿写入其他模型。 +6. 发布仅提供 Windows x64 便携 ZIP。旧发布页及附件会移除,旧标签和 Git 提交继续保留。 diff --git a/frontend/package.json b/frontend/package.json index 628db1e..d58134d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,17 +7,18 @@ "dev": "vite", "build:dev": "node ./scripts/run-vite-build.mjs --minify false --mode development", "build": "node ./scripts/run-vite-build.mjs --mode production", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "dependencies": { "@floating-ui/dom": "^1.7.4", "@iconify/json": "^2.2.447", - "@wailsio/runtime": "latest", + "@wailsio/runtime": "3.0.0-alpha.79", "chart.js": "^4.5.1", "copy-text-to-clipboard": "^3.2.2", "dayjs": "^1.11.20", "resize-observer-polyfill": "^1.5.1", - "vue": "^3.5.22", + "vue": "3.5.42", "vue-chartjs": "^5.3.3", "vue-router": "^4.6.3" }, @@ -27,12 +28,24 @@ "@vitejs/plugin-vue-jsx": "^5.1.1", "autoprefixer": "^10.5.0", "code-inspector-plugin": "^1.4.3", - "postcss": "^8.5.14", + "postcss": "8.5.23", "tailwindcss": "^3.4.17", - "vite": "^7.1.11", + "vite": "7.3.7", "vite-plugin-top-level-await": "^1.6.0" }, "browserslist": [ - "Safari >= 13" - ] + "Safari >= 14.1" + ], + "resolutions": { + "**/postcss": "8.5.23", + "**/@babel/core": "7.29.6", + "**/nanoid": "3.3.18", + "**/postcss-selector-parser": "6.1.5", + "**/browserslist": "4.28.7", + "**/baseline-browser-mapping": "2.11.0", + "**/source-map-js": "1.2.2", + "**/uuid": "11.1.1", + "vite/picomatch": "4.0.4", + "**/tinyglobby/picomatch": "4.0.4" + } } diff --git a/frontend/src/services/clientApi.js b/frontend/src/services/clientApi.js index 74dc7d9..b4a4b0e 100644 --- a/frontend/src/services/clientApi.js +++ b/frontend/src/services/clientApi.js @@ -1,3 +1,4 @@ +import { apiLogEntry } from "@/utils/configSafety.js"; import { GetState, LoadUserConfig, @@ -32,19 +33,8 @@ import { Call } from "@wailsio/runtime"; const API_LOG_PREFIX = "[clientApi]"; const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService"; -function logSuccess(name, payload, result) { - console.log(`${API_LOG_PREFIX} ${name} response`, { - payload, - result, - }); -} - -function logError(name, payload, error) { - console.error(`${API_LOG_PREFIX} ${name} error`, { - payload, - error, - }); -} +function logSuccess(name) { console.info(API_LOG_PREFIX, apiLogEntry(name)); } +function logError(name) { console.error(API_LOG_PREFIX, apiLogEntry(name, true)); } function withApiLogging(name, payload, runner) { return Promise.resolve() diff --git a/frontend/src/state/appState.js b/frontend/src/state/appState.js index 74d39d0..7cf69e2 100644 --- a/frontend/src/state/appState.js +++ b/frontend/src/state/appState.js @@ -1,3 +1,4 @@ +import { safePreferences, editorConflict } from "@/utils/configSafety.js"; import { computed, reactive, watchSyncEffect } from "vue"; import { Events } from "@wailsio/runtime"; import dayjs from "dayjs"; @@ -580,7 +581,9 @@ function loadCachedState() { if (!parsed || typeof parsed !== "object") { return {}; } - return parsed; + const safe = safePreferences(parsed); + window.localStorage.setItem(APP_STATE_STORAGE_KEY, JSON.stringify(safe)); + return safe; } catch (_error) { return {}; } @@ -591,6 +594,8 @@ function normalizeConfig(source) { const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {}; const homeMetrics = raw.homeMetrics && typeof raw.homeMetrics === "object" ? raw.homeMetrics : {}; return { + revision: asString(raw.revision || raw.configRevision), + cliHttpsListenAddr: asString(raw.cliHttpsListenAddr || raw.configCLIHTTPSListenAddr), log: asBoolean(raw.log), providerStreamIdleTimeout: asPositiveInteger(raw.providerStreamIdleTimeout), backendListenAddr: asString(raw.configBackendListenAddr) || asString(raw.backendListenAddr), @@ -649,6 +654,8 @@ function applyHomeMetrics(raw) { function buildConfigPayload(source = appState) { const normalized = normalizeConfig(source); return { + revision: normalized.revision, + cliHttpsListenAddr: normalized.cliHttpsListenAddr, log: normalized.log, providerStreamIdleTimeout: normalized.providerStreamIdleTimeout, backendListenAddr: normalized.backendListenAddr, @@ -664,19 +671,12 @@ function buildConfigPayload(source = appState) { // sanitizePayloadForCache 剥离敏感字段(apiKey/tabServerToken),避免 localStorage 明文落盘。 // 缓存仅用于启动瞬间兜底展示,真实配置始终以后端返回为准。 -function sanitizePayloadForCache(payload) { - return { - ...payload, - modelAdapters: (payload.modelAdapters || []).map(({ apiKey, ...adapter }) => ({ - ...adapter, - apiKey: "", - })), - tabServerToken: "", - }; -} +function sanitizePayloadForCache(payload) { return safePreferences(payload); } function applyConfigToState(config, { modelAdaptersOnly = false } = {}) { const normalized = normalizeConfig(config); + appState.configRevision = normalized.revision; + appState.configCLIHTTPSListenAddr = normalized.cliHttpsListenAddr; if (modelAdaptersOnly) { appState.modelAdapters = normalized.modelAdapters; return normalized; @@ -802,6 +802,8 @@ const cachedConfig = normalizeConfig(cachedState); export const appState = reactive({ appVersion: "", + configRevision: "", + configCLIHTTPSListenAddr: "", modelAdapters: cachedConfig.modelAdapters, modelAdapterTestResults: {}, configBackendListenAddr: cachedConfig.backendListenAddr, @@ -845,29 +847,7 @@ watchSyncEffect(() => { try { window.localStorage.setItem( APP_STATE_STORAGE_KEY, - JSON.stringify({ - ...sanitizePayloadForCache(buildConfigPayload()), - serviceRunning: appState.serviceRunning, - backendRunning: appState.backendRunning, - proxyRunning: appState.proxyRunning, - serviceLastError: appState.serviceLastError, - serviceListenAddr: appState.serviceListenAddr, - configBackendListenAddr: appState.configBackendListenAddr, - configProxyListenAddr: appState.configProxyListenAddr, - backendListenAddr: appState.backendListenAddr, - proxyListenAddr: appState.proxyListenAddr, - tabServerBaseURL: appState.tabServerBaseURL, - tabServerToken: "", - cursorSettingsApplied: appState.cursorSettingsApplied, - netProxySource: appState.netProxySource, - netProxyActive: appState.netProxyActive, - netProxyUsingSystem: appState.netProxyUsingSystem, - netProxyUsingEnv: appState.netProxyUsingEnv, - netProxyHttp: appState.netProxyHttp, - netProxyHttps: appState.netProxyHttps, - netProxyPacIgnored: appState.netProxyPacIgnored, - netProxyDescription: appState.netProxyDescription, - }), + JSON.stringify(sanitizePayloadForCache(buildConfigPayload())), ); } catch (_error) { // ignore local persistence failures @@ -978,7 +958,9 @@ export async function runModelAdapterTest(adapter) { } export async function persistUserConfig() { + const expected = appState.configRevision; const currentConfig = await loadPersistedUserConfig(); + if (currentConfig.revision !== expected) return {ok:false,error:"CONFIG_CONFLICT: 配置已更改,请重新加载;草稿已保留"}; return persistConfigPayload({ ...currentConfig, modelAdapters: normalizeModelAdapters(appState.modelAdapters), @@ -1077,8 +1059,9 @@ export async function reloadUserConfig(options = {}) { return config; } -export async function saveModelAdapterAt(index, adapter) { +export async function saveModelAdapterAt(index, adapter, expectedRevision, originalID) { const currentConfig = await loadPersistedUserConfig(); + if (editorConflict(currentConfig, expectedRevision, index, originalID)) return { ok:false, error:"CONFIG_CONFLICT: 配置已在其他窗口更改,请刷新版本后再保存;草稿已保留" }; const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); const nextAdapter = normalizeModelAdapter(adapter); const targetIndex = index >= 0 && index < nextAdapters.length ? index : nextAdapters.length; @@ -1311,7 +1294,7 @@ export async function openModelConfigWindow() { } export async function openModelEditorWindow(index, adapter) { - const adapterJSON = JSON.stringify(normalizeModelAdapter(adapter)); + const adapterJSON = JSON.stringify({ ...normalizeModelAdapter(adapter), __revision: appState.configRevision, __originalID: adapter?.id || "" }); await openModelEditor(index, adapterJSON); } diff --git a/frontend/src/utils/configSafety.js b/frontend/src/utils/configSafety.js new file mode 100644 index 0000000..4c79700 --- /dev/null +++ b/frontend/src/utils/configSafety.js @@ -0,0 +1,21 @@ +// Cache an explicit preference allowlist; model and authentication data stay in memory. +export function safePreferences(payload = {}) { + const metrics = payload.homeMetrics || {}; + return { + homeMetrics: { + includeCacheWriteInHitRate: metrics.includeCacheWriteInHitRate === true, + persistentMode: metrics.persistentMode !== false, + refreshIntervalSeconds: Number(metrics.refreshIntervalSeconds) || 60, + }, + }; +} + +export function apiLogEntry(operation, failed = false) { + return { operation, status: failed ? "failed" : "completed" }; +} + +export function editorConflict(current, expectedRevision, index, originalID) { + if (expectedRevision !== undefined && current.revision !== expectedRevision) return true; + if (index >= 0 && (!current.modelAdapters[index] || (originalID && current.modelAdapters[index].id !== originalID))) return true; + return false; +} diff --git a/frontend/src/views/ModelEditor.vue b/frontend/src/views/ModelEditor.vue index d4ed2fb..1a6a57c 100644 --- a/frontend/src/views/ModelEditor.vue +++ b/frontend/src/views/ModelEditor.vue @@ -20,6 +20,7 @@ import { OPENAI_ENDPOINT_RESPONSES, OPENAI_EXTRA_PARAMS_DEFAULT_JSON, runModelAdapterTest, + reloadUserConfig, saveModelAdapterAt, toUserError, validateModelAdapters, @@ -53,6 +54,8 @@ const openAIEndpointOptions = [ ]; const editorIndex = ref(-1); +const editorRevision = ref(""); +const originalID = ref(""); const draft = reactive(createEmptyModelAdapter()); const errorMessage = ref(""); const loading = ref(true); @@ -142,6 +145,8 @@ async function loadContext() { const ctx = await getModelEditorContext(); editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1; const parsed = JSON.parse(ctx.adapterJSON || "{}"); + editorRevision.value = parsed.__revision || appState.configRevision; + originalID.value = parsed.__originalID || parsed.id || ""; Object.assign(draft, normalizeModelAdapter(parsed)); if (!draft.type) { draft.type = "openai"; @@ -163,7 +168,7 @@ async function persistDraft() { return { ok: false, error: singleCheck, adapter: null }; } - const result = await saveModelAdapterAt(editorIndex.value, adapter); + const result = await saveModelAdapterAt(editorIndex.value, adapter, editorRevision.value, originalID.value); if (!result.ok) { errorMessage.value = result.error; return { ok: false, error: result.error, adapter: null }; @@ -175,6 +180,8 @@ async function persistDraft() { if (result.adapter) { Object.assign(draft, normalizeModelAdapter(result.adapter)); } + editorRevision.value = appState.configRevision; + originalID.value = result.adapter?.id || originalID.value; errorMessage.value = ""; return { ok: true, @@ -183,6 +190,19 @@ async function persistDraft() { }; } +async function refreshEditorRevision() { + try { + const latest = await reloadUserConfig(); + if (editorIndex.value >= 0) { + const index = latest.modelAdapters.findIndex(item => item.id === originalID.value); + if (index < 0) { errorMessage.value = "原模型已被修改或删除,草稿仍保留。请先核对其他窗口中的修改。"; return; } + editorIndex.value = index; + } + editorRevision.value = latest.revision; + errorMessage.value = "版本已刷新,草稿保持不变。请核对后保存。"; + } catch (error) { errorMessage.value = toUserError(error); } +} + async function handleSave() { const result = await persistDraft(); if (!result.ok) { @@ -542,6 +562,7 @@ onMounted(async () => { > {{ errorMessage }} + diff --git a/frontend/test/appState.test.js b/frontend/test/appState.test.js new file mode 100644 index 0000000..d3c9b9d --- /dev/null +++ b/frontend/test/appState.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; + +test('real app state preserves the CLI port and rejects stale editor/full-form saves', async () => { + const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); + const adapter={id:'adapter-1',type:'openai',displayName:'Fixture',baseURL:'https://fixture.invalid',apiKey:'fixture-only',tooltipData:'fixture',modelID:'model',reasoningEffort:'high',openAIEndpoint:'/v1/responses'}; + let config={revision:'r1',backendListenAddr:'127.0.0.1:28090',proxyListenAddr:'127.0.0.1:28080',cliHttpsListenAddr:'127.0.0.1:39999',modelAdapters:[adapter],routing:{mode:'local'},homeMetrics:{persistentMode:true,refreshIntervalSeconds:60},tabServerBaseURL:'',tabServerToken:''}; + const writes=[]; + globalThis.__configReviewFixture={load:()=>structuredClone(config),save:payload=>{assert.equal(payload.revision,config.revision);writes.push(structuredClone(payload));config={...structuredClone(payload),revision:'r'+(writes.length+1)};}}; + const apiNames=['getAppVersion','getHomeMetricsSummary','getModelAdapterTestResults','getProxyState','listRemoteModels','openConfigWindow','loadUserConfig','openLogsDirectory','openModelConfig','openModelEditor','saveUserConfig','startProxyService','stopProxyService','testModelAdapter']; + const server=await createServer({root,configFile:false,optimizeDeps:{noDiscovery:true,include:[]},ssr:{noExternal:['@wailsio/runtime']},resolve:{alias:{'@':path.join(root,'src')}},server:{middlewareMode:true,hmr:false,watch:null},plugins:[{ + name:'isolated-native-bridge-fixture',enforce:'pre', + resolveId(id){if(id==='@wailsio/runtime')return '\0review-runtime';if(id.replaceAll('\\','/').match(/\/services\/clientApi(?:\.js)?$/))return '\0review-api';}, + load(id){if(id==='\0review-runtime')return 'export const Events={On:()=>()=>{}};';if(id==='\0review-api')return apiNames.map(name=>`export async function ${name}(...args){${name==='loadUserConfig'?'return globalThis.__configReviewFixture.load();':name==='saveUserConfig'?'return globalThis.__configReviewFixture.save(args[0]);':'return {};'}}`).join('\n');}, + }]}); + try { + const state=await server.ssrLoadModule('/src/state/appState.js'); + globalThis.window={setInterval:()=>1,clearInterval:()=>{}}; + await state.reloadUserConfig(); + assert.equal(state.appState.configCLIHTTPSListenAddr,'127.0.0.1:39999'); + const saved=await state.saveIncludeCacheWriteInHitRate(true); + assert.equal(saved.ok,true);assert.equal(writes[0].cliHttpsListenAddr,'127.0.0.1:39999');assert.equal(writes[0].revision,'r1'); + config={...config,revision:'other-window'}; + const draft={...adapter,displayName:'unsaved draft'}; + const stale=await state.saveModelAdapterAt(0,draft,'r2','adapter-1'); + assert.equal(stale.ok,false);assert.match(stale.error,/CONFIG_CONFLICT/);assert.equal(draft.displayName,'unsaved draft');assert.equal(writes.length,1); + const full=await state.persistUserConfig();assert.equal(full.ok,false);assert.equal(writes.length,1); + } finally {await server.close();delete globalThis.__configReviewFixture;delete globalThis.window;} +}); diff --git a/frontend/test/configSafety.test.js b/frontend/test/configSafety.test.js new file mode 100644 index 0000000..a3bf49b --- /dev/null +++ b/frontend/test/configSafety.test.js @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {safePreferences, apiLogEntry, editorConflict} from '../src/utils/configSafety.js'; + +test('cached preferences never include model, headers, token or error bodies', () => { + const marker = 'SENTINEL_PRIVATE_CREDENTIAL'; + const input = {revision:'revision', modelAdapters:[{apiKey:marker,customHeadersJSON:JSON.stringify({Authorization:marker}),openAIExtraParamsJSON:marker}],tabServerToken:marker,serviceLastError:marker,homeMetrics:{refreshIntervalSeconds:30,persistentMode:false,secret:marker}}; + const safe = safePreferences(input); + assert.equal(JSON.stringify(safe).includes(marker),false); + assert.deepEqual(safe,{homeMetrics:{includeCacheWriteInHitRate:false,persistentMode:false,refreshIntervalSeconds:30}}); + assert.deepEqual(safePreferences(safe),safe); +}); + +test('API log records are metadata only for both success and error', () => { + assert.deepEqual(apiLogEntry('SaveUserConfig'),{operation:'SaveUserConfig',status:'completed'}); + assert.deepEqual(apiLogEntry('SaveUserConfig',true),{operation:'SaveUserConfig',status:'failed'}); +}); + +test('editor rejects stale revision, deleted target and changed identity', () => { + const current={revision:'new',modelAdapters:[{id:'a'},{id:'b'}]}; + assert.equal(editorConflict(current,'old',0,'a'),true); + assert.equal(editorConflict(current,'new',2,'b'),true); + assert.equal(editorConflict(current,'new',0,'b'),true); + assert.equal(editorConflict(current,'new',1,'b'),false); + assert.equal(editorConflict(current,'new',-1,''),false); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2b941f6..ed38e55 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -18,8 +18,8 @@ export default defineConfig({ }, }, build: { - target: ["es2019", "safari13"], - cssTarget: "safari13", + target: ["es2019", "safari14.1"], + cssTarget: "safari14.1", }, plugins: [ isDev && diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1d59950..4c7c34c 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -16,22 +16,31 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@^7.29.7": + version "7.29.7" + resolved "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" + integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw== + dependencies: + "@babel/helper-validator-identifier" "^7.29.7" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.28.6": version "7.29.0" resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== -"@babel/core@^7.29.0": - version "7.29.0" - resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" - integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== +"@babel/core@7.29.6", "@babel/core@^7.29.0": + version "7.29.6" + resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.6.tgz#2f2c2ca1728ae73c9b68ece91d1ae6dee18ff83a" + integrity sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA== dependencies: "@babel/code-frame" "^7.29.0" - "@babel/generator" "^7.29.0" + "@babel/generator" "^7.29.6" "@babel/helper-compilation-targets" "^7.28.6" "@babel/helper-module-transforms" "^7.28.6" - "@babel/helpers" "^7.28.6" - "@babel/parser" "^7.29.0" + "@babel/helpers" "^7.29.2" + "@babel/parser" "^7.29.3" "@babel/template" "^7.28.6" "@babel/traverse" "^7.29.0" "@babel/types" "^7.29.0" @@ -53,6 +62,17 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" +"@babel/generator@^7.29.6": + version "7.29.13" + resolved "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.13.tgz#89bd5d368cde7437920ab3f2e67ef24f0ecfe620" + integrity sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw== + dependencies: + "@babel/parser" "^7.29.13" + "@babel/types" "^7.29.11" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.27.3": version "7.27.3" resolved "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" @@ -148,23 +168,33 @@ resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== +"@babel/helper-string-parser@^7.29.11": + version "7.29.13" + resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz#00a43cc19b8f84e9e3f17f32813e69ae9bfceba8" + integrity sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ== + "@babel/helper-validator-identifier@^7.28.5": version "7.28.5" resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/helper-validator-identifier@^7.29.7": + version "7.29.7" + resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" + integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== + "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== -"@babel/helpers@^7.28.6": - version "7.28.6" - resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" - integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== +"@babel/helpers@^7.29.2": + version "7.29.13" + resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.13.tgz#0f72c9bb2bef0c35f7ef41442358c873487071c5" + integrity sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ== dependencies: - "@babel/template" "^7.28.6" - "@babel/types" "^7.28.6" + "@babel/template" "^7.29.7" + "@babel/types" "^7.29.11" "@babel/parser@^7.28.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": version "7.29.0" @@ -173,6 +203,13 @@ dependencies: "@babel/types" "^7.29.0" +"@babel/parser@^7.29.13", "@babel/parser@^7.29.3", "@babel/parser@^7.29.7", "@babel/parser@^7.29.8": + version "7.29.13" + resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.13.tgz#dbba85be72b94635123d594035fd087fe7e42bb3" + integrity sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ== + dependencies: + "@babel/types" "^7.29.11" + "@babel/plugin-syntax-jsx@^7.27.1": version "7.28.6" resolved "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" @@ -207,6 +244,15 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" +"@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.4", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": version "7.29.0" resolved "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" @@ -228,6 +274,14 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@babel/types@^7.29.11", "@babel/types@^7.29.7": + version "7.29.11" + resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.11.tgz#774cffe8cac2ca494f1334e4052002c96bf628fc" + integrity sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ== + dependencies: + "@babel/helper-string-parser" "^7.29.11" + "@babel/helper-validator-identifier" "^7.29.7" + "@code-inspector/core@1.4.3": version "1.4.3" resolved "https://registry.npmmirror.com/@code-inspector/core/-/core-1.4.3.tgz#e3a1fb225d6ae66c28fce02063bce36f0d138c57" @@ -276,135 +330,135 @@ dependencies: "@code-inspector/core" "1.4.3" -"@esbuild/aix-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" - integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== -"@esbuild/android-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" - integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== -"@esbuild/android-arm@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" - integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== -"@esbuild/android-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" - integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== -"@esbuild/darwin-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" - integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== -"@esbuild/darwin-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" - integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== -"@esbuild/freebsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" - integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== -"@esbuild/freebsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" - integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== -"@esbuild/linux-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" - integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== -"@esbuild/linux-arm@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" - integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== -"@esbuild/linux-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" - integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== -"@esbuild/linux-loong64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" - integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== -"@esbuild/linux-mips64el@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" - integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== -"@esbuild/linux-ppc64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" - integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== -"@esbuild/linux-riscv64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" - integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== -"@esbuild/linux-s390x@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" - integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== -"@esbuild/linux-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" - integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== -"@esbuild/netbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" - integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== -"@esbuild/netbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" - integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== -"@esbuild/openbsd-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" - integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== -"@esbuild/openbsd-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" - integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== -"@esbuild/openharmony-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" - integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== -"@esbuild/sunos-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" - integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== -"@esbuild/win32-arm64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" - integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== -"@esbuild/win32-ia32@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" - integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== -"@esbuild/win32-x64@0.27.3": - version "0.27.3" - resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" - integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== "@floating-ui/core@^1.7.5": version "1.7.5" @@ -808,6 +862,17 @@ estree-walker "^2.0.2" source-map-js "^1.2.1" +"@vue/compiler-core@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz#9d159ca5b1e7d792a42e9b1ddf2fc31edc9333dc" + integrity sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/shared" "3.5.42" + entities "^7.0.1" + estree-walker "^2.0.2" + source-map-js "^1.2.1" + "@vue/compiler-dom@3.5.29": version "3.5.29" resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz#055cf5492005249591c7fb45868a874468e4ffb3" @@ -816,6 +881,14 @@ "@vue/compiler-core" "3.5.29" "@vue/shared" "3.5.29" +"@vue/compiler-dom@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz#2ac9570010bbffb7c08d8a904a956ecbabcdf6c6" + integrity sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA== + dependencies: + "@vue/compiler-core" "3.5.42" + "@vue/shared" "3.5.42" + "@vue/compiler-dom@^3.5.13": version "3.5.30" resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz#a38dbdd520479244c8b673123b4bd06a82e733ee" @@ -824,7 +897,22 @@ "@vue/compiler-core" "3.5.30" "@vue/shared" "3.5.30" -"@vue/compiler-sfc@3.5.29", "@vue/compiler-sfc@^3.5.22": +"@vue/compiler-sfc@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz#502c50e7864a980b7f147b78974e3b49fefcc16d" + integrity sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw== + dependencies: + "@babel/parser" "^7.29.8" + "@vue/compiler-core" "3.5.42" + "@vue/compiler-dom" "3.5.42" + "@vue/compiler-ssr" "3.5.42" + "@vue/shared" "3.5.42" + estree-walker "^2.0.2" + magic-string "^0.30.21" + postcss "^8.5.19" + source-map-js "^1.2.1" + +"@vue/compiler-sfc@^3.5.22": version "3.5.29" resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz#8b1b0707fb53c122fedd566244a564eaf40ee71f" integrity sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA== @@ -847,43 +935,52 @@ "@vue/compiler-dom" "3.5.29" "@vue/shared" "3.5.29" +"@vue/compiler-ssr@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz#a3121ad517e9062121762e452f125674cc6162fa" + integrity sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA== + dependencies: + "@vue/compiler-dom" "3.5.42" + "@vue/shared" "3.5.42" + "@vue/devtools-api@^6.6.4": version "6.6.4" resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== -"@vue/reactivity@3.5.29": - version "3.5.29" - resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.29.tgz#68fbaedcc2584328060edceedc4d4370b49c9fab" - integrity sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA== +"@vue/reactivity@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz#800220f023d36fd04d94460c7c7ab5808f8ae75f" + integrity sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ== dependencies: - "@vue/shared" "3.5.29" + "@vue/shared" "3.5.42" -"@vue/runtime-core@3.5.29": - version "3.5.29" - resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.29.tgz#fbd34110aa47e74a22fe430734018bec56913b40" - integrity sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg== +"@vue/runtime-core@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz#a8af85746d6f9a9f1f8e4626be24eb0b8c9a1e72" + integrity sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA== dependencies: - "@vue/reactivity" "3.5.29" - "@vue/shared" "3.5.29" + "@vue/reactivity" "3.5.42" + "@vue/shared" "3.5.42" -"@vue/runtime-dom@3.5.29": - version "3.5.29" - resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz#4eb686b22178f13c262ea1d0d7171a3134ced21f" - integrity sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg== +"@vue/runtime-dom@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz#c7b487a92b2c381db547c1aa5040bd7febb40448" + integrity sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g== dependencies: - "@vue/reactivity" "3.5.29" - "@vue/runtime-core" "3.5.29" - "@vue/shared" "3.5.29" + "@vue/reactivity" "3.5.42" + "@vue/runtime-core" "3.5.42" + "@vue/shared" "3.5.42" csstype "^3.2.3" -"@vue/server-renderer@3.5.29": - version "3.5.29" - resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.29.tgz#9cbcbf676b2976fbb8171d5de79501894ba84cad" - integrity sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g== +"@vue/server-renderer@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz#27c3d4743273bf7e839fc5a5e2f7db25e8d779e8" + integrity sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ== dependencies: - "@vue/compiler-ssr" "3.5.29" - "@vue/shared" "3.5.29" + "@vue/compiler-ssr" "3.5.42" + "@vue/runtime-dom" "3.5.42" + "@vue/shared" "3.5.42" "@vue/shared@3.5.29", "@vue/shared@^3.5.22": version "3.5.29" @@ -895,9 +992,14 @@ resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz#5d7a0d3ca151647484303fd9f057e2e13ecb80ef" integrity sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ== -"@wailsio/runtime@latest": +"@vue/shared@3.5.42": + version "3.5.42" + resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz#a413cf21262502662354cda3b65ab31bca979202" + integrity sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g== + +"@wailsio/runtime@3.0.0-alpha.79": version "3.0.0-alpha.79" - resolved "https://registry.yarnpkg.com/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz#d43310f5b87dc46142756abf0973ba07277be66e" + resolved "https://registry.npmmirror.com/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz#d43310f5b87dc46142756abf0973ba07277be66e" integrity sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA== ansi-styles@^4.1.0: @@ -941,15 +1043,10 @@ autoprefixer@^10.5.0: picocolors "^1.1.1" postcss-value-parser "^4.2.0" -baseline-browser-mapping@^2.10.12: - version "2.10.28" - resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz#9ba8e7b5ef9d40ceb3dd1cdcb936f63383b6833b" - integrity sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA== - -baseline-browser-mapping@^2.9.0: - version "2.10.0" - resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" - integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== +baseline-browser-mapping@2.11.0, baseline-browser-mapping@^2.10.44: + version "2.11.0" + resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz#25453328838032c7a514fbd238a171f744e9e854" + integrity sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ== binary-extensions@^2.0.0: version "2.3.0" @@ -963,26 +1060,15 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0: - version "4.28.1" - resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" - integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== +browserslist@4.28.7, browserslist@^4.24.0, browserslist@^4.28.2: + version "4.28.7" + resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.7.tgz#409046517fccd2e51cdc20f077454b7141184028" + integrity sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw== dependencies: - baseline-browser-mapping "^2.9.0" - caniuse-lite "^1.0.30001759" - electron-to-chromium "^1.5.263" - node-releases "^2.0.27" - update-browserslist-db "^1.2.0" - -browserslist@^4.28.2: - version "4.28.2" - resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" - integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== - dependencies: - baseline-browser-mapping "^2.10.12" - caniuse-lite "^1.0.30001782" - electron-to-chromium "^1.5.328" - node-releases "^2.0.36" + baseline-browser-mapping "^2.10.44" + caniuse-lite "^1.0.30001806" + electron-to-chromium "^1.5.393" + node-releases "^2.0.51" update-browserslist-db "^1.2.3" camelcase-css@^2.0.1: @@ -990,16 +1076,16 @@ camelcase-css@^2.0.1: resolved "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -caniuse-lite@^1.0.30001759: - version "1.0.30001775" - resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz#9572266e3f7f77efee5deac1efeb4795879d1b7f" - integrity sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A== - -caniuse-lite@^1.0.30001782, caniuse-lite@^1.0.30001787: +caniuse-lite@^1.0.30001787: version "1.0.30001792" resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5" integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw== +caniuse-lite@^1.0.30001806: + version "1.0.30001823" + resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz#331a7cb49aa317517697e76b0b1295f2225959fe" + integrity sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw== + chalk@4.1.1: version "4.1.1" resolved "https://registry.npmmirror.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" @@ -1115,15 +1201,10 @@ dotenv@^16.1.4: resolved "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== -electron-to-chromium@^1.5.263: - version "1.5.302" - resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" - integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== - -electron-to-chromium@^1.5.328: - version "1.5.353" - resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz#01e8a8e25a0bf13e631106045f177d0568ca91c2" - integrity sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w== +electron-to-chromium@^1.5.393: + version "1.5.478" + resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz#e1d6d5279242d2f90fa287a8343dc04ddc0c52ec" + integrity sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg== entities@^7.0.1: version "7.0.1" @@ -1135,37 +1216,37 @@ es-errors@^1.3.0: resolved "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -esbuild@^0.27.0: - version "0.27.3" - resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" - integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== +"esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.3" - "@esbuild/android-arm" "0.27.3" - "@esbuild/android-arm64" "0.27.3" - "@esbuild/android-x64" "0.27.3" - "@esbuild/darwin-arm64" "0.27.3" - "@esbuild/darwin-x64" "0.27.3" - "@esbuild/freebsd-arm64" "0.27.3" - "@esbuild/freebsd-x64" "0.27.3" - "@esbuild/linux-arm" "0.27.3" - "@esbuild/linux-arm64" "0.27.3" - "@esbuild/linux-ia32" "0.27.3" - "@esbuild/linux-loong64" "0.27.3" - "@esbuild/linux-mips64el" "0.27.3" - "@esbuild/linux-ppc64" "0.27.3" - "@esbuild/linux-riscv64" "0.27.3" - "@esbuild/linux-s390x" "0.27.3" - "@esbuild/linux-x64" "0.27.3" - "@esbuild/netbsd-arm64" "0.27.3" - "@esbuild/netbsd-x64" "0.27.3" - "@esbuild/openbsd-arm64" "0.27.3" - "@esbuild/openbsd-x64" "0.27.3" - "@esbuild/openharmony-arm64" "0.27.3" - "@esbuild/sunos-x64" "0.27.3" - "@esbuild/win32-arm64" "0.27.3" - "@esbuild/win32-ia32" "0.27.3" - "@esbuild/win32-x64" "0.27.3" + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" escalade@^3.2.0: version "3.2.0" @@ -1363,20 +1444,15 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nanoid@3.3.18, nanoid@^3.3.16: + version "3.3.18" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== -node-releases@^2.0.27: - version "2.0.27" - resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" - integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== - -node-releases@^2.0.36: - version "2.0.38" - resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz#791569b9e4424a044e12c3abfad418ed83ce9947" - integrity sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw== +node-releases@^2.0.51: + version "2.0.63" + resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.63.tgz#ad6035ec7745f8bc3b14f6500cd70d5545634612" + integrity sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -1408,21 +1484,16 @@ picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== +picomatch@4.0.4, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.2" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== -picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== - -picomatch@^4.0.4: - version "4.0.4" - resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" - integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== - pify@^2.3.0: version "2.3.0" resolved "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -1471,10 +1542,10 @@ postcss-nested@^6.2.0: dependencies: postcss-selector-parser "^6.1.1" -postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: - version "6.1.2" - resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" - integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== +postcss-selector-parser@6.1.5, postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: + version "6.1.5" + resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz#20e988ce8eab87575d54824d9800236c5fd872ca" + integrity sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q== dependencies: cssesc "^3.0.0" util-deprecate "^1.0.2" @@ -1484,21 +1555,12 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.47, postcss@^8.5.14: - version "8.5.14" - resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" - integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== +postcss@8.5.23, postcss@^8.4.47, postcss@^8.5.19, postcss@^8.5.6: + version "8.5.23" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" + integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== dependencies: - nanoid "^3.3.11" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -postcss@^8.5.6: - version "8.5.6" - resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== - dependencies: - nanoid "^3.3.11" + nanoid "^3.3.16" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -1587,10 +1649,10 @@ semver@^6.3.1: resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -source-map-js@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" - integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +source-map-js@1.2.2, source-map-js@^1.2.1: + version "1.2.2" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab" + integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== sucrase@^3.35.0: version "3.35.1" @@ -1687,7 +1749,7 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -update-browserslist-db@^1.2.0, update-browserslist-db@^1.2.3: +update-browserslist-db@^1.2.3: version "1.2.3" resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== @@ -1700,10 +1762,10 @@ util-deprecate@^1.0.2: resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -uuid@10.0.0: - version "10.0.0" - resolved "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" - integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uuid@10.0.0, uuid@11.1.1: + version "11.1.1" + resolved "https://registry.npmmirror.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad" + integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ== vite-plugin-top-level-await@^1.6.0: version "1.6.0" @@ -1715,12 +1777,12 @@ vite-plugin-top-level-await@^1.6.0: "@swc/wasm" "^1.12.14" uuid "10.0.0" -vite@^7.1.11: - version "7.3.1" - resolved "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" - integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== +vite@7.3.7: + version "7.3.7" + resolved "https://registry.npmmirror.com/vite/-/vite-7.3.7.tgz#017a80fdce051003a4aa290dddd0b159aa769282" + integrity sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA== dependencies: - esbuild "^0.27.0" + esbuild "^0.27.0 || ^0.28.0" fdir "^6.5.0" picomatch "^4.0.3" postcss "^8.5.6" @@ -1741,16 +1803,16 @@ vue-router@^4.6.3: dependencies: "@vue/devtools-api" "^6.6.4" -vue@^3.5.22: - version "3.5.29" - resolved "https://registry.npmmirror.com/vue/-/vue-3.5.29.tgz#859a1cc5219eb1228c7ce6f355b27de080517111" - integrity sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA== +vue@3.5.42: + version "3.5.42" + resolved "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz#6099e750a19c68a5c09f785808d5abb01558a3c1" + integrity sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA== dependencies: - "@vue/compiler-dom" "3.5.29" - "@vue/compiler-sfc" "3.5.29" - "@vue/runtime-dom" "3.5.29" - "@vue/server-renderer" "3.5.29" - "@vue/shared" "3.5.29" + "@vue/compiler-dom" "3.5.42" + "@vue/compiler-sfc" "3.5.42" + "@vue/runtime-dom" "3.5.42" + "@vue/server-renderer" "3.5.42" + "@vue/shared" "3.5.42" yallist@^3.0.2: version "3.1.1" diff --git a/go.mod b/go.mod index 36625d9..c60b7cc 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module cursor -go 1.25.0 +go 1.26.0 + +toolchain go1.26.10 require ( codeberg.org/readeck/go-readability/v2 v2.1.1 @@ -17,8 +19,8 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/sergi/go-diff v1.4.0 github.com/wailsapp/wails/v3 v3.0.0-alpha.74 - golang.org/x/net v0.49.0 - golang.org/x/sys v0.42.0 + golang.org/x/net v0.62.0 + golang.org/x/sys v0.49.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 @@ -40,8 +42,8 @@ require ( github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.7.0 // indirect - github.com/go-git/go-git/v5 v5.16.4 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-git/go-git/v5 v5.19.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -56,7 +58,7 @@ require ( github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -64,9 +66,9 @@ require ( github.com/skeema/knownhosts v1.3.2 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/text v0.43.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.72.3 // indirect diff --git a/go.sum b/go.sum index c1accaf..c8dd04f 100644 --- a/go.sum +++ b/go.sum @@ -52,12 +52,12 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= -github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= -github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -120,8 +120,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= -github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -168,17 +168,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -189,8 +189,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= +golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -198,8 +198,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -218,8 +218,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -229,8 +229,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -241,16 +241,16 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= diff --git a/internal/app/runner.go b/internal/app/runner.go index fd74992..c3ae0e0 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -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), diff --git a/internal/app/shortcut_windows.go b/internal/app/shortcut_windows.go index 6278c6d..8041244 100644 --- a/internal/app/shortcut_windows.go +++ b/internal/app/shortcut_windows.go @@ -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) diff --git a/internal/appdata/migrate.go b/internal/appdata/migrate.go index c0f0aa8..12ea90e 100644 --- a/internal/appdata/migrate.go +++ b/internal/appdata/migrate.go @@ -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 diff --git a/internal/backend/README.md b/internal/backend/README.md index b80a763..ec79859 100644 --- a/internal/backend/README.md +++ b/internal/backend/README.md @@ -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/` 只保留必要文本运行日志 diff --git a/internal/backend/agent/bridge/interaction/bridge.go b/internal/backend/agent/bridge/interaction/bridge.go index caa6cf5..c8c52f7 100644 --- a/internal/backend/agent/bridge/interaction/bridge.go +++ b/internal/backend/agent/bridge/interaction/bridge.go @@ -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 { diff --git a/internal/backend/agent/model/http_integration_test.go b/internal/backend/agent/model/http_integration_test.go new file mode 100644 index 0000000..702bbb0 --- /dev/null +++ b/internal/backend/agent/model/http_integration_test.go @@ -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") + } +} diff --git a/internal/backend/agent/model/request_override.go b/internal/backend/agent/model/request_override.go index 1962ffc..7acb0bd 100644 --- a/internal/backend/agent/model/request_override.go +++ b/internal/backend/agent/model/request_override.go @@ -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 } diff --git a/internal/backend/agent/model/retry.go b/internal/backend/agent/model/retry.go index 57ed600..97ee5be 100644 --- a/internal/backend/agent/model/retry.go +++ b/internal/backend/agent/model/retry.go @@ -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() diff --git a/internal/backend/forwarder/file_store.go b/internal/backend/forwarder/file_store.go index a1b5ffb..b288b2e 100644 --- a/internal/backend/forwarder/file_store.go +++ b/internal/backend/forwarder/file_store.go @@ -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 diff --git a/internal/backend/forwarder/file_store_security_test.go b/internal/backend/forwarder/file_store_security_test.go new file mode 100644 index 0000000..0283d85 --- /dev/null +++ b/internal/backend/forwarder/file_store_security_test.go @@ -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) + } + } +} diff --git a/internal/backend/forwarder/usage_store_idempotency_test.go b/internal/backend/forwarder/usage_store_idempotency_test.go index b33e405..cb05fd9 100644 --- a/internal/backend/forwarder/usage_store_idempotency_test.go +++ b/internal/backend/forwarder/usage_store_idempotency_test.go @@ -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, diff --git a/internal/backend/host.go b/internal/backend/host.go index 4069c7d..5c12f6a 100644 --- a/internal/backend/host.go +++ b/internal/backend/host.go @@ -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) } diff --git a/internal/backend/host_lifecycle_test.go b/internal/backend/host_lifecycle_test.go new file mode 100644 index 0000000..c4171e8 --- /dev/null +++ b/internal/backend/host_lifecycle_test.go @@ -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() +} diff --git a/internal/backend/server/config/manager.go b/internal/backend/server/config/manager.go index 17a735f..7797194 100644 --- a/internal/backend/server/config/manager.go +++ b/internal/backend/server/config/manager.go @@ -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)) } } } diff --git a/internal/backend/server/config/resolver.go b/internal/backend/server/config/resolver.go index a857443..805cdce 100644 --- a/internal/backend/server/config/resolver.go +++ b/internal/backend/server/config/resolver.go @@ -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) { diff --git a/internal/backend/server/config/revision.go b/internal/backend/server/config/revision.go new file mode 100644 index 0000000..45fa4d3 --- /dev/null +++ b/internal/backend/server/config/revision.go @@ -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 +} diff --git a/internal/backend/server/config/revision_test.go b/internal/backend/server/config/revision_test.go new file mode 100644 index 0000000..3a6e3c5 --- /dev/null +++ b/internal/backend/server/config/revision_test.go @@ -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) + } +} diff --git a/internal/backend/server/config/store.go b/internal/backend/server/config/store.go index 65d7755..d03b089 100644 --- a/internal/backend/server/config/store.go +++ b/internal/backend/server/config/store.go @@ -2,6 +2,7 @@ package config import ( "context" + "cursor/internal/securefile" "errors" "fmt" "os" @@ -75,7 +76,7 @@ func (store *Store) Load(_ context.Context) (Config, error) { if err := store.saveLocked(defaultConfig); err != nil { return DefaultConfig(), err } - return defaultConfig, nil + return withRevision(defaultConfig), nil } return DefaultConfig(), fmt.Errorf("读取用户配置失败: %w", err) } @@ -93,7 +94,7 @@ func (store *Store) Load(_ context.Context) (Config, error) { return DefaultConfig(), err } } - return normalized, nil + return withRevision(normalized), nil } func (store *Store) Save(_ context.Context, cfg Config) (Config, error) { @@ -109,10 +110,20 @@ func (store *Store) Save(_ context.Context, cfg Config) (Config, error) { store.mu.Lock() defer store.mu.Unlock() + if cfg.Revision != "" { + existing, err := store.readCurrentLocked() + if err != nil { + return Config{}, err + } + if withRevision(existing).Revision != cfg.Revision { + return Config{}, ErrConfigConflict + } + normalized.LastAgentModelHash = existing.LastAgentModelHash + } if err := store.saveLocked(normalized); err != nil { return Config{}, err } - return normalized, nil + return withRevision(normalized), nil } func (store *Store) saveLocked(normalized Config) error { @@ -125,11 +136,7 @@ func (store *Store) saveLocked(normalized Config) error { return fmt.Errorf("序列化用户配置失败: %w", err) } - tempPath := store.path + ".tmp" - if err := writeFileSync(tempPath, data); err != nil { - return fmt.Errorf("写入临时配置失败: %w", err) - } - if err := os.Rename(tempPath, store.path); err != nil { + if err := securefile.Write(store.path, data); err != nil { return fmt.Errorf("保存用户配置失败: %w", err) } return nil @@ -181,3 +188,31 @@ func yamlHasKey(raw []byte, key string) bool { } return false } + +func (store *Store) readCurrentLocked() (Config, error) { + data, err := os.ReadFile(store.path) + if errors.Is(err, os.ErrNotExist) { + return DefaultConfig(), nil + } + if err != nil { + return Config{}, err + } + var current Config + if err = yaml.Unmarshal(data, ¤t); err != nil { + return Config{}, err + } + return NormalizeConfig(current) +} +func (store *Store) updateLastAgentModelHash(value string) (Config, error) { + store.mu.Lock() + defer store.mu.Unlock() + cfg, err := store.readCurrentLocked() + if err != nil { + return Config{}, err + } + cfg.LastAgentModelHash = value + if err = store.saveLocked(cfg); err != nil { + return Config{}, err + } + return withRevision(cfg), nil +} diff --git a/internal/backend/server/config/types.go b/internal/backend/server/config/types.go index e182b6b..2a1fc9b 100644 --- a/internal/backend/server/config/types.go +++ b/internal/backend/server/config/types.go @@ -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 不能为空") diff --git a/internal/backend/tab_forward_test.go b/internal/backend/tab_forward_test.go new file mode 100644 index 0000000..2ea871c --- /dev/null +++ b/internal/backend/tab_forward_test.go @@ -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") + } +} diff --git a/internal/certs/ca.go b/internal/certs/ca.go index 75ae069..e847d79 100644 --- a/internal/certs/ca.go +++ b/internal/certs/ca.go @@ -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...) } diff --git a/internal/certs/ca.key b/internal/certs/ca.key deleted file mode 100644 index c0a78a3..0000000 --- a/internal/certs/ca.key +++ /dev/null @@ -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----- diff --git a/internal/certs/local.go b/internal/certs/local.go new file mode 100644 index 0000000..caa1d1c --- /dev/null +++ b/internal/certs/local.go @@ -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}) +} diff --git a/internal/certs/local_test.go b/internal/certs/local_test.go new file mode 100644 index 0000000..789c347 --- /dev/null +++ b/internal/certs/local_test.go @@ -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") + } +} diff --git a/internal/client/config.go b/internal/client/config.go index 59a5e34..c2389b0 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -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 { diff --git a/internal/client/cursor.go b/internal/client/cursor.go index cf240c8..3e8e4f9 100644 --- a/internal/client/cursor.go +++ b/internal/client/cursor.go @@ -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 相关的逻辑。 diff --git a/internal/client/lifecycle.go b/internal/client/lifecycle.go index dbfb317..eb35646 100644 --- a/internal/client/lifecycle.go +++ b/internal/client/lifecycle.go @@ -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) } } diff --git a/internal/client/lifecycle_race_test.go b/internal/client/lifecycle_race_test.go new file mode 100644 index 0000000..5cfe178 --- /dev/null +++ b/internal/client/lifecycle_race_test.go @@ -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() +} diff --git a/internal/client/models_fetch.go b/internal/client/models_fetch.go index f615633..eafac46 100644 --- a/internal/client/models_fetch.go +++ b/internal/client/models_fetch.go @@ -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) } diff --git a/internal/client/models_fetch_test.go b/internal/client/models_fetch_test.go index e1896e3..e89d352 100644 --- a/internal/client/models_fetch_test.go +++ b/internal/client/models_fetch_test.go @@ -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 结尾去重后应保留原路径前缀") } -} \ No newline at end of file +} diff --git a/internal/client/observer.go b/internal/client/observer.go index 5ee413d..4ab5a81 100644 --- a/internal/client/observer.go +++ b/internal/client/observer.go @@ -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 diff --git a/internal/client/service.go b/internal/client/service.go index 29a74b0..8fb9699 100644 --- a/internal/client/service.go +++ b/internal/client/service.go @@ -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 +} diff --git a/internal/cursor/cert_migration.go b/internal/cursor/cert_migration.go new file mode 100644 index 0000000..1bbefc7 --- /dev/null +++ b/internal/cursor/cert_migration.go @@ -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 +} diff --git a/internal/cursor/cli.go b/internal/cursor/cli.go index 63ffb7e..3a93d17 100644 --- a/internal/cursor/cli.go +++ b/internal/cursor/cli.go @@ -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)) } diff --git a/internal/cursor/cli_preload.cjs b/internal/cursor/cli_preload.cjs new file mode 100644 index 0000000..80c3769 --- /dev/null +++ b/internal/cursor/cli_preload.cjs @@ -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] : [])); +}; diff --git a/internal/cursor/cli_security_test.go b/internal/cursor/cli_security_test.go new file mode 100644 index 0000000..2cc86bc --- /dev/null +++ b/internal/cursor/cli_security_test.go @@ -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") + } +} diff --git a/internal/cursor/darwin_cert.go b/internal/cursor/darwin_cert.go index 5175078..63db493 100644 --- a/internal/cursor/darwin_cert.go +++ b/internal/cursor/darwin_cert.go @@ -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) + }) } diff --git a/internal/cursor/launcher_windows_test.go b/internal/cursor/launcher_windows_test.go new file mode 100644 index 0000000..1509a86 --- /dev/null +++ b/internal/cursor/launcher_windows_test.go @@ -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") + } +} diff --git a/internal/cursor/localize_other.go b/internal/cursor/localize_other.go index b1fcfa1..ccd2300 100644 --- a/internal/cursor/localize_other.go +++ b/internal/cursor/localize_other.go @@ -14,3 +14,8 @@ func mergeNodeSearchPath() (string, bool) { func detectCursorInstallPathFromRegistry() string { return "" } + +// scanDriveRootsForCursor is only used as a Windows fallback. +func scanDriveRootsForCursor() string { + return "" +} diff --git a/internal/cursor/projects.go b/internal/cursor/projects.go index f55b45d..70ed692 100644 --- a/internal/cursor/projects.go +++ b/internal/cursor/projects.go @@ -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'", diff --git a/internal/cursor/projects_test.go b/internal/cursor/projects_test.go index 9a626a6..9ec6720 100644 --- a/internal/cursor/projects_test.go +++ b/internal/cursor/projects_test.go @@ -186,4 +186,4 @@ func TestRemoveWorkspaceStorageEntries(t *testing.T) { t.Fatalf("removed = %d, want 0", removed) } }) -} \ No newline at end of file +} diff --git a/internal/cursor/proxy_settings.go b/internal/cursor/proxy_settings.go new file mode 100644 index 0000000..2b8f708 --- /dev/null +++ b/internal/cursor/proxy_settings.go @@ -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 +} diff --git a/internal/cursor/security_regression_test.go b/internal/cursor/security_regression_test.go new file mode 100644 index 0000000..7da727f --- /dev/null +++ b/internal/cursor/security_regression_test.go @@ -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") + } +} diff --git a/internal/cursor/settings.go b/internal/cursor/settings.go index 41be5e6..1543161 100644 --- a/internal/cursor/settings.go +++ b/internal/cursor/settings.go @@ -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() diff --git a/internal/cursor/windows_cert.go b/internal/cursor/windows_cert.go index 9d8d100..c943cd6 100644 --- a/internal/cursor/windows_cert.go +++ b/internal/cursor/windows_cert.go @@ -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)) } diff --git a/internal/localaccess/origin.go b/internal/localaccess/origin.go new file mode 100644 index 0000000..9afac04 --- /dev/null +++ b/internal/localaccess/origin.go @@ -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) + }) +} diff --git a/internal/localaccess/origin_test.go b/internal/localaccess/origin_test.go new file mode 100644 index 0000000..6d57650 --- /dev/null +++ b/internal/localaccess/origin_test.go @@ -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) + } + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 1f711ee..905c7d2 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -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) } diff --git a/internal/logger/redact.go b/internal/logger/redact.go new file mode 100644 index 0000000..6bc1d38 --- /dev/null +++ b/internal/logger/redact.go @@ -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)} +} diff --git a/internal/logger/redact_test.go b/internal/logger/redact_test.go new file mode 100644 index 0000000..75a5b23 --- /dev/null +++ b/internal/logger/redact_test.go @@ -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) + } + } +} diff --git a/internal/mitm/service.go b/internal/mitm/service.go index b7fefda..0f6825a 100644 --- a/internal/mitm/service.go +++ b/internal/mitm/service.go @@ -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")) diff --git a/internal/modelchannel/identity.go b/internal/modelchannel/identity.go index 7084bfc..eb4e57b 100644 --- a/internal/modelchannel/identity.go +++ b/internal/modelchannel/identity.go @@ -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 缺少主机名") } diff --git a/internal/netproxy/netproxy.go b/internal/netproxy/netproxy.go index 12df0aa..a0f92c4 100644 --- a/internal/netproxy/netproxy.go +++ b/internal/netproxy/netproxy.go @@ -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. diff --git a/internal/netproxy/redirect.go b/internal/netproxy/redirect.go new file mode 100644 index 0000000..f026af0 --- /dev/null +++ b/internal/netproxy/redirect.go @@ -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 +} diff --git a/internal/netproxy/redirect_test.go b/internal/netproxy/redirect_test.go new file mode 100644 index 0000000..ffc4b1f --- /dev/null +++ b/internal/netproxy/redirect_test.go @@ -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") + } +} diff --git a/internal/securefile/file.go b/internal/securefile/file.go new file mode 100644 index 0000000..ee570bb --- /dev/null +++ b/internal/securefile/file.go @@ -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 +} diff --git a/internal/securefile/file_unix.go b/internal/securefile/file_unix.go new file mode 100644 index 0000000..b496e12 --- /dev/null +++ b/internal/securefile/file_unix.go @@ -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) } diff --git a/internal/securefile/file_windows.go b/internal/securefile/file_windows.go new file mode 100644 index 0000000..c0e2f95 --- /dev/null +++ b/internal/securefile/file_windows.go @@ -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{}) +} diff --git a/internal/skills/safety.go b/internal/skills/safety.go new file mode 100644 index 0000000..d767b04 --- /dev/null +++ b/internal/skills/safety.go @@ -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 + }) +} diff --git a/internal/skills/safety_test.go b/internal/skills/safety_test.go new file mode 100644 index 0000000..6356dce --- /dev/null +++ b/internal/skills/safety_test.go @@ -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") + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go index 98a11f3..1f419e8 100644 --- a/internal/skills/skills.go +++ b/internal/skills/skills.go @@ -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) }) } diff --git a/main.go b/main.go index 5c06f5b..44d8749 100644 --- a/main.go +++ b/main.go @@ -2,8 +2,10 @@ package main import ( "cursor/internal/app" + "cursor/internal/buildinfo" "cursor/internal/logger" "embed" + "fmt" "os" ) @@ -17,11 +19,22 @@ var appIcon []byte var trayIcon []byte func main() { + disableAutoStart := false + for _, arg := range os.Args[1:] { + if arg == "--version" { + fmt.Println(buildinfo.CurrentVersion()) + return + } + if arg == "--no-auto-start" { + disableAutoStart = true + } + } logger.Init() if err := app.Run(app.EmbeddedResources{ - Assets: assets, - AppIcon: appIcon, - TrayIcon: trayIcon, + DisableAutoStart: disableAutoStart, + Assets: assets, + AppIcon: appIcon, + TrayIcon: trayIcon, }); err != nil { logger.Errorf("app run failed: %v", err) os.Exit(1) diff --git a/release-notes.md b/release-notes.md index ac4f123..db0a6ea 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,7 +1,9 @@ -# Local-Cursor +# Local-Cursor v1.1.9 -- 个人使用版本 -- 移除广告系统 -- 移除自动更新 -- 默认端口改为 28090/28080,避免与原版冲突 -- Tab 补全服务改为可配置 +- 改用本机独立 CA,首次升级需重新授权信任;恢复 CLI TLS 验证并支持中文、空格和引号路径。 +- 修复配置端口丢失、多窗口覆盖、损坏配置被删除、最近项目记录字段丢失和异常关闭后端口占用。 +- 移除日志与浏览器缓存中的敏感配置;保护本机私钥和日志,限制无关网站访问本地 RPC。 +- 修复 Protobuf 锁复制和用量统计测试,完善模型请求头、流式响应、取消及重定向处理。 +- 独立 Tab 服务增加必填客户端令牌;远程部署需同步配置 clientToken/tabServerToken。 +- 加固 Skills 文件操作,升级 Go 和前端安全依赖,固定可重现构建工具链与版本资源。 +- 仅提供 Windows x64 便携版。详细测试、平台限制与升级说明见 docs/review-v1.1.9.md。 diff --git a/scripts/release/main.go b/scripts/release/main.go index e64a12e..a5c9c54 100644 --- a/scripts/release/main.go +++ b/scripts/release/main.go @@ -50,10 +50,12 @@ var releaseAssets = []assetSpec{ func main() { if len(os.Args) < 2 { - exitf("usage: go run ./scripts/release [flags]") + exitf("usage: go run ./scripts/release [flags]") } switch os.Args[1] { + case "sync-metadata": + runMetadata(os.Args[2:]) case "version": runVersion(os.Args[2:]) case "notes": diff --git a/scripts/release/metadata.go b/scripts/release/metadata.go new file mode 100644 index 0000000..dfe84c5 --- /dev/null +++ b/scripts/release/metadata.go @@ -0,0 +1,65 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +type metadataRule struct{ path, pattern, version string } + +// syncMetadata updates version fields only. Regenerating Wails build templates +// during a release used to overwrite customized platform packaging tasks. +func syncMetadata(root, version string) error { + if !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$`).MatchString(version) { + return fmt.Errorf("invalid release version") + } + core := strings.SplitN(version, "-", 2)[0] + rules := []metadataRule{ + {"build/windows/info.json", `("(?:file_version|product_version|FileVersion|ProductVersion)"\s*:\s*")[^"]*(")`, core}, + {"build/windows/wails.exe.manifest", `(]*version=")[^"]*(")`, core + ".0"}, + {"build/windows/nsis/wails_tools.nsh", `(!define INFO_PRODUCTVERSION ")[^"]*(")`, core}, + {"build/darwin/Info.plist", `(CFBundle(?:ShortVersionString|Version)\s*)[^<]*()`, version}, + {"build/darwin/Info.dev.plist", `(CFBundle(?:ShortVersionString|Version)\s*)[^<]*()`, version}, + {"build/linux/nfpm/nfpm.yaml", `(?m)(^version:\s*")[^"]*(")`, version}, + } + updates := make(map[string][]byte) + for _, rule := range rules { + file := filepath.Join(root, filepath.FromSlash(rule.path)) + data, err := os.ReadFile(file) + if err != nil { + return err + } + pattern := regexp.MustCompile(rule.pattern) + if !pattern.Match(data) { + return fmt.Errorf("missing version field in %s", rule.path) + } + updates[file] = pattern.ReplaceAll(data, []byte("${1}"+rule.version+"${2}")) + } + for file, data := range updates { + info, err := os.Stat(file) + if err != nil { + return err + } + if err = os.WriteFile(file, data, info.Mode().Perm()); err != nil { + return err + } + } + return nil +} + +func runMetadata(args []string) { + flags := flag.NewFlagSet("sync-metadata", flag.ExitOnError) + root := flags.String("root", ".", "project root") + _ = flags.Parse(args) + version, err := readVersion(filepath.Join(*root, "build", "config.yml")) + if err != nil { + exitErr(err) + } + if err = syncMetadata(*root, version); err != nil { + exitErr(err) + } +} diff --git a/scripts/release/metadata_test.go b/scripts/release/metadata_test.go new file mode 100644 index 0000000..8f98b74 --- /dev/null +++ b/scripts/release/metadata_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMetadataSyncPreservesCustomBuildFilesAndContent(t *testing.T) { + root := t.TempDir() + files := []string{"build/windows/info.json", "build/windows/wails.exe.manifest", "build/windows/nsis/wails_tools.nsh", "build/darwin/Info.plist", "build/darwin/Info.dev.plist", "build/linux/nfpm/nfpm.yaml"} + for _, file := range files { + data, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(file))) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(root, filepath.FromSlash(file)) + _ = os.MkdirAll(filepath.Dir(target), 0700) + if err = os.WriteFile(target, data, 0600); err != nil { + t.Fatal(err) + } + } + task := filepath.Join(root, "build", "windows", "Taskfile.yml") + _ = os.WriteFile(task, []byte("custom packaging must stay"), 0600) + if err := syncMetadata(root, "9.8.7"); err != nil { + t.Fatal(err) + } + for _, file := range files { + data, _ := os.ReadFile(filepath.Join(root, filepath.FromSlash(file))) + if !strings.Contains(string(data), "9.8.7") { + t.Fatalf("version not updated: %s", file) + } + } + data, _ := os.ReadFile(task) + if string(data) != "custom packaging must stay" { + t.Fatal("custom task overwritten") + } + data, _ = os.ReadFile(filepath.Join(root, "build", "windows", "info.json")) + if !strings.Contains(string(data), "Local-Cursor") { + t.Fatal("product metadata lost") + } +}