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