2 Commits

Author SHA1 Message Date
FuDie0915
fbbf8cab27 Release v1.1.9: audited security and reliability fixes
改用本机独立 CA,恢复 CLI TLS 校验并保护凭据。
修复配置并发保存、Cursor 数据完整性、会话路径、代理生命周期和 Skills 文件操作。
为独立 Tab 服务增加客户端鉴权,升级依赖与工具链,完善模型请求和发布元信息同步。

验证:两个 Go 模块测试/vet、关键模块 race、前端测试/构建/审计通过;
Go 扫描无可达漏洞,macOS/Linux 可行模块交叉编译通过。
Windows 隔离启动通过。界面点击与原生平台验证的限制见 docs/review-v1.1.9.md。

本提交为已验证源码树的版本标记;保留上一提交与原有标签历史。
2026-11-10 10:00:18 +08:00
FuDie0915
188225fae6 Ð޸´ȫÏîĿÉó²éÎÊÌⲢ·¢²¼ v1.1.9
¸ÄÓñ¾»ú¶ÀÁ¢ CA£¬»ָ´ CLI TLS УÑ飬±£»¤ƾ¾ÝÓëÅäÖò¢·¢±£´档
Ð޸´ Cursor Êý¾ݡ¢»Ự·¾¶¡¢´úÀíÉúÃüÖÜÆڡ¢ģÐÍÇëÇóÓë Skills Îļþ²Ù×÷¡£
Ϊ¶ÀÁ¢ Tab ·þÎñÔö¼ӿͻ§¶˼øȨ£¬Éý¼¶ÒÀÀµÓ빤¾ßÁ´£¬ÍêÉƷ¢²¼ԪÐÅϢͬ²½¡£

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

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ cmd/ads-server/ads-server
cmd/ads-server/ads-server-linux-amd64.tar cmd/ads-server/ads-server-linux-amd64.tar
cursor-tab-server/cursor-tab-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

View File

@@ -4,6 +4,8 @@
> 基于 [cursor-byok](https://github.com/leookun/cursor-byok.git) 衍生,感谢原作者。本项目仅供学习与个人使用,请支持正版 Cursor。 > 基于 [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) - Node.js 20+(含 yarn,可用 corepack)
- [Wails v3 CLI](https://wails.io/):`go install github.com/wailsapp/wails/v3/cmd/wails3@latest` - [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) - 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/ ~/.cursor-local-assistant-v2/
├── config.yaml # 用户配置 ├── config.yaml # 用户配置
├── data/ ├── data/
│ ├── ca.crt # 注入 CA 证书 │ ├── ca.crt # 注入本机 CA 证书
│ ├── local-ca.pem # 本机私有 CA(不得共享或提交)
│ ├── codebase-index/ # 代码库索引 │ ├── codebase-index/ # 代码库索引
│ └── docs-index/ # 文档索引 │ └── docs-index/ # 文档索引
├── history/ ├── history/

View File

@@ -281,7 +281,7 @@ tasks:
msg: "未检测到 wails3,请先安装 Wails v3 CLI。" msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds: cmds:
- mkdir -p "{{.WINDOWS_RELEASE_DIR}}" - mkdir -p "{{.WINDOWS_RELEASE_DIR}}"
- task: common:update:build-assets - cmd: go run ./scripts/release sync-metadata
- task: windows:create:zip - task: windows:create:zip
vars: vars:
ARCH: amd64 ARCH: amd64
@@ -307,7 +307,7 @@ tasks:
msg: "未检测到 wails3,请先安装 Wails v3 CLI。" msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds: cmds:
- task: release:clean - task: release:clean
- task: common:update:build-assets - cmd: go run ./scripts/release sync-metadata
- task: release:notes - task: release:notes
- task: release:build:macos:arm64 - task: release:build:macos:arm64
- task: release:build:macos:amd64 - task: release:build:macos:amd64
@@ -323,7 +323,7 @@ tasks:
msg: "未检测到 wails3,请先安装 Wails v3 CLI。" msg: "未检测到 wails3,请先安装 Wails v3 CLI。"
cmds: cmds:
- task: release:ensure:dir - task: release:ensure:dir
- task: common:update:build-assets - cmd: go run ./scripts/release sync-metadata
- task: release:notes - task: release:notes
- task: release:build:linux:amd64 - task: release:build:linux:amd64

View File

@@ -1,6 +1,6 @@
version: "3" version: "3"
# 构建元信息(修改后可执行:wails3 task common:update:build-assets) # 构建元信息(修改版本后执行:go run ./scripts/release sync-metadata)
info: info:
companyName: "Local-Cursor" companyName: "Local-Cursor"
productName: "Local-Cursor" productName: "Local-Cursor"
@@ -8,7 +8,7 @@ info:
description: "Local-Cursor" description: "Local-Cursor"
copyright: "© 2026, Local-Cursor" copyright: "© 2026, Local-Cursor"
comments: "Local-Cursor" comments: "Local-Cursor"
version: "1.1.8" version: "1.1.9"
dev_mode: dev_mode:
root_path: . root_path: .

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.8</string> <string>1.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.1.8</string> <string>1.1.9</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.15.0</string> <string>10.15.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.8</string> <string>1.1.9</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.1.8</string> <string>1.1.9</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>10.15.0</string> <string>10.15.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>

View File

@@ -3,7 +3,7 @@
ARG BASE_IMAGE=debian:trixie ARG BASE_IMAGE=debian:trixie
FROM ${BASE_IMAGE} 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 \ RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \

View File

@@ -6,7 +6,7 @@
name: "Local-Cursor" name: "Local-Cursor"
arch: ${GOARCH} arch: ${GOARCH}
platform: "linux" platform: "linux"
version: "1.1.8" version: "1.1.9"
section: "default" section: "default"
priority: "extra" priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}> maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>

View File

@@ -126,7 +126,7 @@ tasks:
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}' USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
create:zip: create:zip:
summary: 生成 Windows ZIP 包(包含 exe 与 certs) summary: 生成 Windows ZIP 包(仅含便携程序)
internal: true internal: true
vars: vars:
ARCH: '{{.ARCH | default ARCH}}' ARCH: '{{.ARCH | default ARCH}}'

View File

@@ -1,12 +1,12 @@
{ {
"fixed": { "fixed": {
"file_version": "1.1.8", "file_version": "1.1.9",
"product_version": "1.1.8" "product_version": "1.1.9"
}, },
"info": { "info": {
"0409": { "0409": {
"FileVersion": "1.1.8", "FileVersion": "1.1.9",
"ProductVersion": "1.1.8", "ProductVersion": "1.1.9",
"CompanyName": "Local-Cursor", "CompanyName": "Local-Cursor",
"FileDescription": "Local-Cursor", "FileDescription": "Local-Cursor",
"LegalCopyright": "© 2026, Local-Cursor", "LegalCopyright": "© 2026, Local-Cursor",

View File

@@ -14,7 +14,7 @@
!define INFO_PRODUCTNAME "Local-Cursor" !define INFO_PRODUCTNAME "Local-Cursor"
!endif !endif
!ifndef INFO_PRODUCTVERSION !ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "1.1.8" !define INFO_PRODUCTVERSION "1.1.9"
!endif !endif
!ifndef INFO_COPYRIGHT !ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, Local-Cursor" !define INFO_COPYRIGHT "© 2026, Local-Cursor"

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.Local-Cursor" version="1.1.8" processorArchitecture="*"/> <assemblyIdentity type="win32" name="com.Local-Cursor" version="1.1.9.0" processorArchitecture="*"/>
<dependency> <dependency>
<dependentAssembly> <dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="1.1.9.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly> </dependentAssembly>
</dependency> </dependency>
<asmv3:application> <asmv3:application>

View File

@@ -1,4 +1,4 @@
FROM golang:1.25 AS build FROM golang:1.26.10 AS build
WORKDIR /src WORKDIR /src

View File

@@ -1,3 +1,16 @@
# 独立 Tab 补全服务
服务监听 18041,支持远程部署。配置文件必须包含两种不同的令牌:
```yaml
token: "你的上游 Cursor 访问令牌"
clientToken: "另行生成的随机客户端访问密钥"
```
调用方使用 `Authorization: Bearer <clientToken>`,Local-Cursor 中对应 `tabServerToken`。服务验证客户端后,用上游 token 转发请求。不要共享上游令牌作为客户端密钥;对外部署请通过 HTTPS 反向代理提供服务。
仅接受 POST 和已配置的 RPC 路由,单次请求最大 64 MiB。缺少或错误的客户端凭据返回 401。配置或令牌不会写入日志。
### 如何获取 token? ### 如何获取 token?
**macos** **macos**
```bash ```bash

View File

@@ -1 +1,3 @@
token: "eyJ..." token: "eyJ..."
# Separate random caller secret; configure the same value as tabServerToken.
clientToken: ""

View File

@@ -1,5 +1,7 @@
module cursor-tab-server module cursor-tab-server
go 1.25 go 1.26.0
toolchain go1.26.10
require gopkg.in/yaml.v3 v3.0.1 require gopkg.in/yaml.v3 v3.0.1

View File

@@ -2,7 +2,9 @@ package main
import ( import (
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
@@ -58,7 +60,8 @@ var defaultUpstreamTargets = map[string]string{
} }
type appConfig struct { type appConfig struct {
Token string Token string `yaml:"token"`
ClientToken string `yaml:"clientToken"`
} }
type serverApp struct { type serverApp struct {
@@ -97,9 +100,65 @@ func newServerApp(cfg appConfig, client *http.Client, upstreamTargets map[string
return app 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) { func (app *serverApp) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if err := app.handleProxy(writer, request); err != nil { w := &trackingWriter{ResponseWriter: writer}
http.Error(writer, err.Error(), http.StatusBadGateway) 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 { if err != nil {
return appConfig{}, err return appConfig{}, err
} }
token, err := parseTokenYAML(contents) var cfg appConfig
if err != nil { if err = yaml.Unmarshal(contents, &cfg); err != nil {
return appConfig{}, err 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) { func parseTokenYAML(contents []byte) (string, error) {
@@ -277,7 +344,9 @@ func buildCursorChecksum(authorization string) string {
} }
func newHTTPClient() *http.Client { 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 { func cloneUpstreamTargets(input map[string]string) map[string]string {

View File

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

View File

@@ -3,7 +3,7 @@
## 目录布局 ## 目录布局
``` ```
Local-Curosr/ Local-Cursor/
├── cmd/ # 独立可执行工具入口 ├── cmd/ # 独立可执行工具入口
│ └── ext-tool/ # proto 提取工具,从 Cursor 扩展源码生成 .proto 定义 │ └── ext-tool/ # proto 提取工具,从 Cursor 扩展源码生成 .proto 定义
├── internal/ # 内部业务包(不对外暴露) ├── internal/ # 内部业务包(不对外暴露)
@@ -39,6 +39,6 @@ Local-Curosr/
## 数据流 ## 数据流
``` ```
Cursor IDE → MITM(38081) → backend(39091) → 路由 → forwarder → agent/model → 上游模型 Cursor IDE → MITM(28080) → backend(28090) → 路由 → forwarder → agent/model → 上游模型
CLI agent → tls-bypass → HTTPS(39092) → 同一 backend mux → 同上 CLI agent → TLS redirect → HTTPS(39092) → 同一 backend mux → 同上
``` ```

View File

@@ -1,41 +1,30 @@
# Windows 构建环境 # 构建与发布环境
以下路径于 2026-11-03 在当前机器核实。项目发布不依赖额外安装 NSIS;使用下方已缓存的可执行文件。 工具路径以当前会话为准,不依赖旧的 fnm 会话目录:
| 工具 | 实际路径 |
|---|---|
| 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 会话中加入后即可使用标准发布任务:
```powershell ```powershell
$env:Path = "C:\Users\FuDie915\AppData\Local\electron-builder\Cache\nsis\nsis-3.0.4.1\Bin;$env:Path" Get-Command git, go, node, yarn, wails3, task, 7z
task release:prepare 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 ```powershell
Local-Cursor.exe go test ./...
Local-Cursor-amd64-installer.exe go vet ./...
local-cursor-v1.1.8-installer.zip go test -race ./internal/backend/... ./internal/client/... ./internal/certs/... ./internal/skills/...
local-cursor-v1.1.8-windows-x64-portable.zip 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`;该选项不自动启用代理、安装根证书或注册快捷方式。

65
docs/review-v1.1.9.md Normal file
View File

@@ -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 提交继续保留。

View File

@@ -7,17 +7,18 @@
"dev": "vite", "dev": "vite",
"build:dev": "node ./scripts/run-vite-build.mjs --minify false --mode development", "build:dev": "node ./scripts/run-vite-build.mjs --minify false --mode development",
"build": "node ./scripts/run-vite-build.mjs --mode production", "build": "node ./scripts/run-vite-build.mjs --mode production",
"preview": "vite preview" "preview": "vite preview",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
"@iconify/json": "^2.2.447", "@iconify/json": "^2.2.447",
"@wailsio/runtime": "latest", "@wailsio/runtime": "3.0.0-alpha.79",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"copy-text-to-clipboard": "^3.2.2", "copy-text-to-clipboard": "^3.2.2",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"vue": "^3.5.22", "vue": "3.5.42",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
@@ -27,12 +28,24 @@
"@vitejs/plugin-vue-jsx": "^5.1.1", "@vitejs/plugin-vue-jsx": "^5.1.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"code-inspector-plugin": "^1.4.3", "code-inspector-plugin": "^1.4.3",
"postcss": "^8.5.14", "postcss": "8.5.23",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "^7.1.11", "vite": "7.3.7",
"vite-plugin-top-level-await": "^1.6.0" "vite-plugin-top-level-await": "^1.6.0"
}, },
"browserslist": [ "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"
}
} }

View File

@@ -1,3 +1,4 @@
import { apiLogEntry } from "@/utils/configSafety.js";
import { import {
GetState, GetState,
LoadUserConfig, LoadUserConfig,
@@ -32,19 +33,8 @@ import { Call } from "@wailsio/runtime";
const API_LOG_PREFIX = "[clientApi]"; const API_LOG_PREFIX = "[clientApi]";
const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService"; const PROXY_SERVICE_NAME = "cursor/internal/bridge.ProxyService";
function logSuccess(name, payload, result) { function logSuccess(name) { console.info(API_LOG_PREFIX, apiLogEntry(name)); }
console.log(`${API_LOG_PREFIX} ${name} response`, { function logError(name) { console.error(API_LOG_PREFIX, apiLogEntry(name, true)); }
payload,
result,
});
}
function logError(name, payload, error) {
console.error(`${API_LOG_PREFIX} ${name} error`, {
payload,
error,
});
}
function withApiLogging(name, payload, runner) { function withApiLogging(name, payload, runner) {
return Promise.resolve() return Promise.resolve()

View File

@@ -1,3 +1,4 @@
import { safePreferences, editorConflict } from "@/utils/configSafety.js";
import { computed, reactive, watchSyncEffect } from "vue"; import { computed, reactive, watchSyncEffect } from "vue";
import { Events } from "@wailsio/runtime"; import { Events } from "@wailsio/runtime";
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -580,7 +581,9 @@ function loadCachedState() {
if (!parsed || typeof parsed !== "object") { if (!parsed || typeof parsed !== "object") {
return {}; return {};
} }
return parsed; const safe = safePreferences(parsed);
window.localStorage.setItem(APP_STATE_STORAGE_KEY, JSON.stringify(safe));
return safe;
} catch (_error) { } catch (_error) {
return {}; return {};
} }
@@ -591,6 +594,8 @@ function normalizeConfig(source) {
const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {}; const routing = raw.routing && typeof raw.routing === "object" ? raw.routing : {};
const homeMetrics = raw.homeMetrics && typeof raw.homeMetrics === "object" ? raw.homeMetrics : {}; const homeMetrics = raw.homeMetrics && typeof raw.homeMetrics === "object" ? raw.homeMetrics : {};
return { return {
revision: asString(raw.revision || raw.configRevision),
cliHttpsListenAddr: asString(raw.cliHttpsListenAddr || raw.configCLIHTTPSListenAddr),
log: asBoolean(raw.log), log: asBoolean(raw.log),
providerStreamIdleTimeout: asPositiveInteger(raw.providerStreamIdleTimeout), providerStreamIdleTimeout: asPositiveInteger(raw.providerStreamIdleTimeout),
backendListenAddr: asString(raw.configBackendListenAddr) || asString(raw.backendListenAddr), backendListenAddr: asString(raw.configBackendListenAddr) || asString(raw.backendListenAddr),
@@ -649,6 +654,8 @@ function applyHomeMetrics(raw) {
function buildConfigPayload(source = appState) { function buildConfigPayload(source = appState) {
const normalized = normalizeConfig(source); const normalized = normalizeConfig(source);
return { return {
revision: normalized.revision,
cliHttpsListenAddr: normalized.cliHttpsListenAddr,
log: normalized.log, log: normalized.log,
providerStreamIdleTimeout: normalized.providerStreamIdleTimeout, providerStreamIdleTimeout: normalized.providerStreamIdleTimeout,
backendListenAddr: normalized.backendListenAddr, backendListenAddr: normalized.backendListenAddr,
@@ -664,19 +671,12 @@ function buildConfigPayload(source = appState) {
// sanitizePayloadForCache 剥离敏感字段(apiKey/tabServerToken),避免 localStorage 明文落盘。 // sanitizePayloadForCache 剥离敏感字段(apiKey/tabServerToken),避免 localStorage 明文落盘。
// 缓存仅用于启动瞬间兜底展示,真实配置始终以后端返回为准。 // 缓存仅用于启动瞬间兜底展示,真实配置始终以后端返回为准。
function sanitizePayloadForCache(payload) { function sanitizePayloadForCache(payload) { return safePreferences(payload); }
return {
...payload,
modelAdapters: (payload.modelAdapters || []).map(({ apiKey, ...adapter }) => ({
...adapter,
apiKey: "",
})),
tabServerToken: "",
};
}
function applyConfigToState(config, { modelAdaptersOnly = false } = {}) { function applyConfigToState(config, { modelAdaptersOnly = false } = {}) {
const normalized = normalizeConfig(config); const normalized = normalizeConfig(config);
appState.configRevision = normalized.revision;
appState.configCLIHTTPSListenAddr = normalized.cliHttpsListenAddr;
if (modelAdaptersOnly) { if (modelAdaptersOnly) {
appState.modelAdapters = normalized.modelAdapters; appState.modelAdapters = normalized.modelAdapters;
return normalized; return normalized;
@@ -802,6 +802,8 @@ const cachedConfig = normalizeConfig(cachedState);
export const appState = reactive({ export const appState = reactive({
appVersion: "", appVersion: "",
configRevision: "",
configCLIHTTPSListenAddr: "",
modelAdapters: cachedConfig.modelAdapters, modelAdapters: cachedConfig.modelAdapters,
modelAdapterTestResults: {}, modelAdapterTestResults: {},
configBackendListenAddr: cachedConfig.backendListenAddr, configBackendListenAddr: cachedConfig.backendListenAddr,
@@ -845,29 +847,7 @@ watchSyncEffect(() => {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
APP_STATE_STORAGE_KEY, APP_STATE_STORAGE_KEY,
JSON.stringify({ JSON.stringify(sanitizePayloadForCache(buildConfigPayload())),
...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,
}),
); );
} catch (_error) { } catch (_error) {
// ignore local persistence failures // ignore local persistence failures
@@ -978,7 +958,9 @@ export async function runModelAdapterTest(adapter) {
} }
export async function persistUserConfig() { export async function persistUserConfig() {
const expected = appState.configRevision;
const currentConfig = await loadPersistedUserConfig(); const currentConfig = await loadPersistedUserConfig();
if (currentConfig.revision !== expected) return {ok:false,error:"CONFIG_CONFLICT: 配置已更改,请重新加载;草稿已保留"};
return persistConfigPayload({ return persistConfigPayload({
...currentConfig, ...currentConfig,
modelAdapters: normalizeModelAdapters(appState.modelAdapters), modelAdapters: normalizeModelAdapters(appState.modelAdapters),
@@ -1077,8 +1059,9 @@ export async function reloadUserConfig(options = {}) {
return config; return config;
} }
export async function saveModelAdapterAt(index, adapter) { export async function saveModelAdapterAt(index, adapter, expectedRevision, originalID) {
const currentConfig = await loadPersistedUserConfig(); const currentConfig = await loadPersistedUserConfig();
if (editorConflict(currentConfig, expectedRevision, index, originalID)) return { ok:false, error:"CONFIG_CONFLICT: 配置已在其他窗口更改,请刷新版本后再保存;草稿已保留" };
const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters); const nextAdapters = normalizeModelAdapters(currentConfig.modelAdapters);
const nextAdapter = normalizeModelAdapter(adapter); const nextAdapter = normalizeModelAdapter(adapter);
const targetIndex = index >= 0 && index < nextAdapters.length ? index : nextAdapters.length; const targetIndex = index >= 0 && index < nextAdapters.length ? index : nextAdapters.length;
@@ -1311,7 +1294,7 @@ export async function openModelConfigWindow() {
} }
export async function openModelEditorWindow(index, adapter) { 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); await openModelEditor(index, adapterJSON);
} }

View File

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

View File

@@ -20,6 +20,7 @@ import {
OPENAI_ENDPOINT_RESPONSES, OPENAI_ENDPOINT_RESPONSES,
OPENAI_EXTRA_PARAMS_DEFAULT_JSON, OPENAI_EXTRA_PARAMS_DEFAULT_JSON,
runModelAdapterTest, runModelAdapterTest,
reloadUserConfig,
saveModelAdapterAt, saveModelAdapterAt,
toUserError, toUserError,
validateModelAdapters, validateModelAdapters,
@@ -53,6 +54,8 @@ const openAIEndpointOptions = [
]; ];
const editorIndex = ref(-1); const editorIndex = ref(-1);
const editorRevision = ref("");
const originalID = ref("");
const draft = reactive(createEmptyModelAdapter()); const draft = reactive(createEmptyModelAdapter());
const errorMessage = ref(""); const errorMessage = ref("");
const loading = ref(true); const loading = ref(true);
@@ -142,6 +145,8 @@ async function loadContext() {
const ctx = await getModelEditorContext(); const ctx = await getModelEditorContext();
editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1; editorIndex.value = typeof ctx.index === "number" ? ctx.index : -1;
const parsed = JSON.parse(ctx.adapterJSON || "{}"); const parsed = JSON.parse(ctx.adapterJSON || "{}");
editorRevision.value = parsed.__revision || appState.configRevision;
originalID.value = parsed.__originalID || parsed.id || "";
Object.assign(draft, normalizeModelAdapter(parsed)); Object.assign(draft, normalizeModelAdapter(parsed));
if (!draft.type) { if (!draft.type) {
draft.type = "openai"; draft.type = "openai";
@@ -163,7 +168,7 @@ async function persistDraft() {
return { ok: false, error: singleCheck, adapter: null }; 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) { if (!result.ok) {
errorMessage.value = result.error; errorMessage.value = result.error;
return { ok: false, error: result.error, adapter: null }; return { ok: false, error: result.error, adapter: null };
@@ -175,6 +180,8 @@ async function persistDraft() {
if (result.adapter) { if (result.adapter) {
Object.assign(draft, normalizeModelAdapter(result.adapter)); Object.assign(draft, normalizeModelAdapter(result.adapter));
} }
editorRevision.value = appState.configRevision;
originalID.value = result.adapter?.id || originalID.value;
errorMessage.value = ""; errorMessage.value = "";
return { return {
ok: true, 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() { async function handleSave() {
const result = await persistDraft(); const result = await persistDraft();
if (!result.ok) { if (!result.ok) {
@@ -542,6 +562,7 @@ onMounted(async () => {
> >
{{ errorMessage }} {{ errorMessage }}
</div> </div>
<Button v-if="errorMessage.includes('CONFIG_CONFLICT')" @click="refreshEditorRevision">刷新版本,保留草稿</Button>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -18,8 +18,8 @@ export default defineConfig({
}, },
}, },
build: { build: {
target: ["es2019", "safari13"], target: ["es2019", "safari14.1"],
cssTarget: "safari13", cssTarget: "safari14.1",
}, },
plugins: [ plugins: [
isDev && isDev &&

View File

@@ -16,22 +16,31 @@
js-tokens "^4.0.0" js-tokens "^4.0.0"
picocolors "^1.1.1" 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": "@babel/compat-data@^7.28.6":
version "7.29.0" version "7.29.0"
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
"@babel/core@^7.29.0": "@babel/core@7.29.6", "@babel/core@^7.29.0":
version "7.29.0" version "7.29.6"
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.6.tgz#2f2c2ca1728ae73c9b68ece91d1ae6dee18ff83a"
integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== integrity sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==
dependencies: dependencies:
"@babel/code-frame" "^7.29.0" "@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-compilation-targets" "^7.28.6"
"@babel/helper-module-transforms" "^7.28.6" "@babel/helper-module-transforms" "^7.28.6"
"@babel/helpers" "^7.28.6" "@babel/helpers" "^7.29.2"
"@babel/parser" "^7.29.0" "@babel/parser" "^7.29.3"
"@babel/template" "^7.28.6" "@babel/template" "^7.28.6"
"@babel/traverse" "^7.29.0" "@babel/traverse" "^7.29.0"
"@babel/types" "^7.29.0" "@babel/types" "^7.29.0"
@@ -53,6 +62,17 @@
"@jridgewell/trace-mapping" "^0.3.28" "@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2" 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": "@babel/helper-annotate-as-pure@^7.27.3":
version "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" 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" resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== 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": "@babel/helper-validator-identifier@^7.28.5":
version "7.28.5" version "7.28.5"
resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== 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": "@babel/helper-validator-option@^7.27.1":
version "7.27.1" version "7.27.1"
resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" resolved "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
"@babel/helpers@^7.28.6": "@babel/helpers@^7.29.2":
version "7.28.6" version "7.29.13"
resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" resolved "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.13.tgz#0f72c9bb2bef0c35f7ef41442358c873487071c5"
integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== integrity sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==
dependencies: dependencies:
"@babel/template" "^7.28.6" "@babel/template" "^7.29.7"
"@babel/types" "^7.28.6" "@babel/types" "^7.29.11"
"@babel/parser@^7.28.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": "@babel/parser@^7.28.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0":
version "7.29.0" version "7.29.0"
@@ -173,6 +203,13 @@
dependencies: dependencies:
"@babel/types" "^7.29.0" "@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": "@babel/plugin-syntax-jsx@^7.27.1":
version "7.28.6" version "7.28.6"
resolved "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" 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/parser" "^7.28.6"
"@babel/types" "^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": "@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" version "7.29.0"
resolved "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" 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-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5" "@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": "@code-inspector/core@1.4.3":
version "1.4.3" version "1.4.3"
resolved "https://registry.npmmirror.com/@code-inspector/core/-/core-1.4.3.tgz#e3a1fb225d6ae66c28fce02063bce36f0d138c57" resolved "https://registry.npmmirror.com/@code-inspector/core/-/core-1.4.3.tgz#e3a1fb225d6ae66c28fce02063bce36f0d138c57"
@@ -276,135 +330,135 @@
dependencies: dependencies:
"@code-inspector/core" "1.4.3" "@code-inspector/core" "1.4.3"
"@esbuild/aix-ppc64@0.27.3": "@esbuild/aix-ppc64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc"
integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==
"@esbuild/android-arm64@0.27.3": "@esbuild/android-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504"
integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==
"@esbuild/android-arm@0.27.3": "@esbuild/android-arm@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381"
integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==
"@esbuild/android-x64@0.27.3": "@esbuild/android-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801"
integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==
"@esbuild/darwin-arm64@0.27.3": "@esbuild/darwin-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc"
integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==
"@esbuild/darwin-x64@0.27.3": "@esbuild/darwin-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de"
integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==
"@esbuild/freebsd-arm64@0.27.3": "@esbuild/freebsd-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e"
integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==
"@esbuild/freebsd-x64@0.27.3": "@esbuild/freebsd-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7"
integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==
"@esbuild/linux-arm64@0.27.3": "@esbuild/linux-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d"
integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==
"@esbuild/linux-arm@0.27.3": "@esbuild/linux-arm@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753"
integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==
"@esbuild/linux-ia32@0.27.3": "@esbuild/linux-ia32@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b"
integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==
"@esbuild/linux-loong64@0.27.3": "@esbuild/linux-loong64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98"
integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==
"@esbuild/linux-mips64el@0.27.3": "@esbuild/linux-mips64el@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca"
integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==
"@esbuild/linux-ppc64@0.27.3": "@esbuild/linux-ppc64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2"
integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==
"@esbuild/linux-riscv64@0.27.3": "@esbuild/linux-riscv64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27"
integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==
"@esbuild/linux-s390x@0.27.3": "@esbuild/linux-s390x@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb"
integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==
"@esbuild/linux-x64@0.27.3": "@esbuild/linux-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485"
integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==
"@esbuild/netbsd-arm64@0.27.3": "@esbuild/netbsd-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" resolved "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1"
integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==
"@esbuild/netbsd-x64@0.27.3": "@esbuild/netbsd-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de"
integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==
"@esbuild/openbsd-arm64@0.27.3": "@esbuild/openbsd-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" resolved "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef"
integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==
"@esbuild/openbsd-x64@0.27.3": "@esbuild/openbsd-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd"
integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==
"@esbuild/openharmony-arm64@0.27.3": "@esbuild/openharmony-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" resolved "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce"
integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==
"@esbuild/sunos-x64@0.27.3": "@esbuild/sunos-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4"
integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==
"@esbuild/win32-arm64@0.27.3": "@esbuild/win32-arm64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532"
integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==
"@esbuild/win32-ia32@0.27.3": "@esbuild/win32-ia32@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7"
integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==
"@esbuild/win32-x64@0.27.3": "@esbuild/win32-x64@0.28.2":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==
"@floating-ui/core@^1.7.5": "@floating-ui/core@^1.7.5":
version "1.7.5" version "1.7.5"
@@ -808,6 +862,17 @@
estree-walker "^2.0.2" estree-walker "^2.0.2"
source-map-js "^1.2.1" 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": "@vue/compiler-dom@3.5.29":
version "3.5.29" version "3.5.29"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz#055cf5492005249591c7fb45868a874468e4ffb3" 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/compiler-core" "3.5.29"
"@vue/shared" "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": "@vue/compiler-dom@^3.5.13":
version "3.5.30" version "3.5.30"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz#a38dbdd520479244c8b673123b4bd06a82e733ee" 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/compiler-core" "3.5.30"
"@vue/shared" "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" version "3.5.29"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz#8b1b0707fb53c122fedd566244a564eaf40ee71f" resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz#8b1b0707fb53c122fedd566244a564eaf40ee71f"
integrity sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA== integrity sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==
@@ -847,43 +935,52 @@
"@vue/compiler-dom" "3.5.29" "@vue/compiler-dom" "3.5.29"
"@vue/shared" "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": "@vue/devtools-api@^6.6.4":
version "6.6.4" version "6.6.4"
resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/reactivity@3.5.29": "@vue/reactivity@3.5.42":
version "3.5.29" version "3.5.42"
resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.29.tgz#68fbaedcc2584328060edceedc4d4370b49c9fab" resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz#800220f023d36fd04d94460c7c7ab5808f8ae75f"
integrity sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA== integrity sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==
dependencies: dependencies:
"@vue/shared" "3.5.29" "@vue/shared" "3.5.42"
"@vue/runtime-core@3.5.29": "@vue/runtime-core@3.5.42":
version "3.5.29" version "3.5.42"
resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.29.tgz#fbd34110aa47e74a22fe430734018bec56913b40" resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz#a8af85746d6f9a9f1f8e4626be24eb0b8c9a1e72"
integrity sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg== integrity sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==
dependencies: dependencies:
"@vue/reactivity" "3.5.29" "@vue/reactivity" "3.5.42"
"@vue/shared" "3.5.29" "@vue/shared" "3.5.42"
"@vue/runtime-dom@3.5.29": "@vue/runtime-dom@3.5.42":
version "3.5.29" version "3.5.42"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz#4eb686b22178f13c262ea1d0d7171a3134ced21f" resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz#c7b487a92b2c381db547c1aa5040bd7febb40448"
integrity sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg== integrity sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==
dependencies: dependencies:
"@vue/reactivity" "3.5.29" "@vue/reactivity" "3.5.42"
"@vue/runtime-core" "3.5.29" "@vue/runtime-core" "3.5.42"
"@vue/shared" "3.5.29" "@vue/shared" "3.5.42"
csstype "^3.2.3" csstype "^3.2.3"
"@vue/server-renderer@3.5.29": "@vue/server-renderer@3.5.42":
version "3.5.29" version "3.5.42"
resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.29.tgz#9cbcbf676b2976fbb8171d5de79501894ba84cad" resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz#27c3d4743273bf7e839fc5a5e2f7db25e8d779e8"
integrity sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g== integrity sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==
dependencies: dependencies:
"@vue/compiler-ssr" "3.5.29" "@vue/compiler-ssr" "3.5.42"
"@vue/shared" "3.5.29" "@vue/runtime-dom" "3.5.42"
"@vue/shared" "3.5.42"
"@vue/shared@3.5.29", "@vue/shared@^3.5.22": "@vue/shared@3.5.29", "@vue/shared@^3.5.22":
version "3.5.29" version "3.5.29"
@@ -895,9 +992,14 @@
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz#5d7a0d3ca151647484303fd9f057e2e13ecb80ef" resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz#5d7a0d3ca151647484303fd9f057e2e13ecb80ef"
integrity sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ== 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" 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== integrity sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==
ansi-styles@^4.1.0: ansi-styles@^4.1.0:
@@ -941,15 +1043,10 @@ autoprefixer@^10.5.0:
picocolors "^1.1.1" picocolors "^1.1.1"
postcss-value-parser "^4.2.0" postcss-value-parser "^4.2.0"
baseline-browser-mapping@^2.10.12: baseline-browser-mapping@2.11.0, baseline-browser-mapping@^2.10.44:
version "2.10.28" version "2.11.0"
resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.28.tgz#9ba8e7b5ef9d40ceb3dd1cdcb936f63383b6833b" resolved "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz#25453328838032c7a514fbd238a171f744e9e854"
integrity sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA== integrity sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==
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==
binary-extensions@^2.0.0: binary-extensions@^2.0.0:
version "2.3.0" version "2.3.0"
@@ -963,26 +1060,15 @@ braces@^3.0.3, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.1.1" fill-range "^7.1.1"
browserslist@^4.24.0: browserslist@4.28.7, browserslist@^4.24.0, browserslist@^4.28.2:
version "4.28.1" version "4.28.7"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.7.tgz#409046517fccd2e51cdc20f077454b7141184028"
integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== integrity sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==
dependencies: dependencies:
baseline-browser-mapping "^2.9.0" baseline-browser-mapping "^2.10.44"
caniuse-lite "^1.0.30001759" caniuse-lite "^1.0.30001806"
electron-to-chromium "^1.5.263" electron-to-chromium "^1.5.393"
node-releases "^2.0.27" node-releases "^2.0.51"
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"
update-browserslist-db "^1.2.3" update-browserslist-db "^1.2.3"
camelcase-css@^2.0.1: 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" resolved "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001759: caniuse-lite@^1.0.30001787:
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:
version "1.0.30001792" version "1.0.30001792"
resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5" resolved "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw== 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: chalk@4.1.1:
version "4.1.1" version "4.1.1"
resolved "https://registry.npmmirror.com/chalk/-/chalk-4.1.1.tgz#c80b3fab28bf6371e6863325eee67e618b77e6ad" 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" resolved "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020"
integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==
electron-to-chromium@^1.5.263: electron-to-chromium@^1.5.393:
version "1.5.302" version "1.5.478"
resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" resolved "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz#e1d6d5279242d2f90fa287a8343dc04ddc0c52ec"
integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== integrity sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==
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==
entities@^7.0.1: entities@^7.0.1:
version "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" resolved "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
esbuild@^0.27.0: "esbuild@^0.27.0 || ^0.28.0":
version "0.27.3" version "0.28.2"
resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816"
integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==
optionalDependencies: optionalDependencies:
"@esbuild/aix-ppc64" "0.27.3" "@esbuild/aix-ppc64" "0.28.2"
"@esbuild/android-arm" "0.27.3" "@esbuild/android-arm" "0.28.2"
"@esbuild/android-arm64" "0.27.3" "@esbuild/android-arm64" "0.28.2"
"@esbuild/android-x64" "0.27.3" "@esbuild/android-x64" "0.28.2"
"@esbuild/darwin-arm64" "0.27.3" "@esbuild/darwin-arm64" "0.28.2"
"@esbuild/darwin-x64" "0.27.3" "@esbuild/darwin-x64" "0.28.2"
"@esbuild/freebsd-arm64" "0.27.3" "@esbuild/freebsd-arm64" "0.28.2"
"@esbuild/freebsd-x64" "0.27.3" "@esbuild/freebsd-x64" "0.28.2"
"@esbuild/linux-arm" "0.27.3" "@esbuild/linux-arm" "0.28.2"
"@esbuild/linux-arm64" "0.27.3" "@esbuild/linux-arm64" "0.28.2"
"@esbuild/linux-ia32" "0.27.3" "@esbuild/linux-ia32" "0.28.2"
"@esbuild/linux-loong64" "0.27.3" "@esbuild/linux-loong64" "0.28.2"
"@esbuild/linux-mips64el" "0.27.3" "@esbuild/linux-mips64el" "0.28.2"
"@esbuild/linux-ppc64" "0.27.3" "@esbuild/linux-ppc64" "0.28.2"
"@esbuild/linux-riscv64" "0.27.3" "@esbuild/linux-riscv64" "0.28.2"
"@esbuild/linux-s390x" "0.27.3" "@esbuild/linux-s390x" "0.28.2"
"@esbuild/linux-x64" "0.27.3" "@esbuild/linux-x64" "0.28.2"
"@esbuild/netbsd-arm64" "0.27.3" "@esbuild/netbsd-arm64" "0.28.2"
"@esbuild/netbsd-x64" "0.27.3" "@esbuild/netbsd-x64" "0.28.2"
"@esbuild/openbsd-arm64" "0.27.3" "@esbuild/openbsd-arm64" "0.28.2"
"@esbuild/openbsd-x64" "0.27.3" "@esbuild/openbsd-x64" "0.28.2"
"@esbuild/openharmony-arm64" "0.27.3" "@esbuild/openharmony-arm64" "0.28.2"
"@esbuild/sunos-x64" "0.27.3" "@esbuild/sunos-x64" "0.28.2"
"@esbuild/win32-arm64" "0.27.3" "@esbuild/win32-arm64" "0.28.2"
"@esbuild/win32-ia32" "0.27.3" "@esbuild/win32-ia32" "0.28.2"
"@esbuild/win32-x64" "0.27.3" "@esbuild/win32-x64" "0.28.2"
escalade@^3.2.0: escalade@^3.2.0:
version "3.2.0" version "3.2.0"
@@ -1363,20 +1444,15 @@ mz@^2.7.0:
object-assign "^4.0.1" object-assign "^4.0.1"
thenify-all "^1.0.0" thenify-all "^1.0.0"
nanoid@^3.3.11: nanoid@3.3.18, nanoid@^3.3.16:
version "3.3.11" version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==
node-releases@^2.0.27: node-releases@^2.0.51:
version "2.0.27" version "2.0.63"
resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.63.tgz#ad6035ec7745f8bc3b14f6500cd70d5545634612"
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== integrity sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==
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==
normalize-path@^3.0.0, normalize-path@~3.0.0: normalize-path@^3.0.0, normalize-path@~3.0.0:
version "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" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== 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: picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.2" version "2.3.2"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== 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: pify@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" resolved "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
@@ -1471,10 +1542,10 @@ postcss-nested@^6.2.0:
dependencies: dependencies:
postcss-selector-parser "^6.1.1" postcss-selector-parser "^6.1.1"
postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2: postcss-selector-parser@6.1.5, postcss-selector-parser@^6.1.1, postcss-selector-parser@^6.1.2:
version "6.1.2" version "6.1.5"
resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz#27ecb41fb0e3b6ba7a1ec84fff347f734c7929de" resolved "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz#20e988ce8eab87575d54824d9800236c5fd872ca"
integrity sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg== integrity sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==
dependencies: dependencies:
cssesc "^3.0.0" cssesc "^3.0.0"
util-deprecate "^1.0.2" 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" resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.47, postcss@^8.5.14: postcss@8.5.23, postcss@^8.4.47, postcss@^8.5.19, postcss@^8.5.6:
version "8.5.14" version "8.5.23"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==
dependencies: dependencies:
nanoid "^3.3.11" nanoid "^3.3.16"
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"
picocolors "^1.1.1" picocolors "^1.1.1"
source-map-js "^1.2.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" resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
source-map-js@^1.2.1: source-map-js@1.2.2, source-map-js@^1.2.1:
version "1.2.1" version "1.2.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz#79fb88d97535a21cb27162cb05d211b67acea4ab"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== integrity sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==
sucrase@^3.35.0: sucrase@^3.35.0:
version "3.35.1" 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" resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== 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" version "1.2.3"
resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" resolved "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== 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" resolved "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
uuid@10.0.0: uuid@10.0.0, uuid@11.1.1:
version "10.0.0" version "11.1.1"
resolved "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" resolved "https://registry.npmmirror.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
vite-plugin-top-level-await@^1.6.0: vite-plugin-top-level-await@^1.6.0:
version "1.6.0" version "1.6.0"
@@ -1715,12 +1777,12 @@ vite-plugin-top-level-await@^1.6.0:
"@swc/wasm" "^1.12.14" "@swc/wasm" "^1.12.14"
uuid "10.0.0" uuid "10.0.0"
vite@^7.1.11: vite@7.3.7:
version "7.3.1" version "7.3.7"
resolved "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" resolved "https://registry.npmmirror.com/vite/-/vite-7.3.7.tgz#017a80fdce051003a4aa290dddd0b159aa769282"
integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== integrity sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==
dependencies: dependencies:
esbuild "^0.27.0" esbuild "^0.27.0 || ^0.28.0"
fdir "^6.5.0" fdir "^6.5.0"
picomatch "^4.0.3" picomatch "^4.0.3"
postcss "^8.5.6" postcss "^8.5.6"
@@ -1741,16 +1803,16 @@ vue-router@^4.6.3:
dependencies: dependencies:
"@vue/devtools-api" "^6.6.4" "@vue/devtools-api" "^6.6.4"
vue@^3.5.22: vue@3.5.42:
version "3.5.29" version "3.5.42"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.29.tgz#859a1cc5219eb1228c7ce6f355b27de080517111" resolved "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz#6099e750a19c68a5c09f785808d5abb01558a3c1"
integrity sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA== integrity sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==
dependencies: dependencies:
"@vue/compiler-dom" "3.5.29" "@vue/compiler-dom" "3.5.42"
"@vue/compiler-sfc" "3.5.29" "@vue/compiler-sfc" "3.5.42"
"@vue/runtime-dom" "3.5.29" "@vue/runtime-dom" "3.5.42"
"@vue/server-renderer" "3.5.29" "@vue/server-renderer" "3.5.42"
"@vue/shared" "3.5.29" "@vue/shared" "3.5.42"
yallist@^3.0.2: yallist@^3.0.2:
version "3.1.1" version "3.1.1"

20
go.mod
View File

@@ -1,6 +1,8 @@
module cursor module cursor
go 1.25.0 go 1.26.0
toolchain go1.26.10
require ( require (
codeberg.org/readeck/go-readability/v2 v2.1.1 codeberg.org/readeck/go-readability/v2 v2.1.1
@@ -17,8 +19,8 @@ require (
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/sergi/go-diff v1.4.0 github.com/sergi/go-diff v1.4.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 github.com/wailsapp/wails/v3 v3.0.0-alpha.74
golang.org/x/net v0.49.0 golang.org/x/net v0.62.0
golang.org/x/sys v0.42.0 golang.org/x/sys v0.49.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.50.1
@@ -40,8 +42,8 @@ require (
github.com/ebitengine/purego v0.9.1 // indirect github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.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/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // 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-ole/go-ole v1.3.0 // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/godbus/dbus/v5 v5.2.2 // 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/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
@@ -64,9 +66,9 @@ require (
github.com/skeema/knownhosts v1.3.2 // indirect github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.24.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.43.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/libc v1.72.3 // indirect

48
go.sum
View File

@@ -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-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 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 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.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
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/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 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-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.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
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/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 h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= 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= 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/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 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= 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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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-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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= 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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= 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-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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 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.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.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 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= 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 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=

View File

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

View File

@@ -37,8 +37,11 @@ func ensureStartMenuShortcut() {
return return
} }
if err := os.MkdirAll(filepath.Dir(lnkPath), 0700); err != nil {
return
}
workDir := filepath.Dir(exePath) 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) cmd := winexec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", psScript)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
logger.Infof("ensureStartMenuShortcut: create failed lnk=%s err=%v", lnkPath, err) logger.Infof("ensureStartMenuShortcut: create failed lnk=%s err=%v", lnkPath, err)

View File

@@ -1,6 +1,7 @@
package appdata package appdata
import ( import (
"cursor/internal/securefile"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -9,19 +10,19 @@ import (
func ensureAssistantHome() error { func ensureAssistantHome() error {
migrateLegacyAssistantHome() migrateLegacyAssistantHome()
if err := os.MkdirAll(RootDir(), 0o755); err != nil { if err := securefile.PrivateDir(RootDir()); err != nil {
return fmt.Errorf("create assistant home: %w", err) 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) 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) 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) 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 fmt.Errorf("create logs root: %w", err)
} }
return nil return nil

View File

@@ -2,7 +2,6 @@
`internal/backend` 当前支持本地助手模式与直连上游模式。 `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/config.yaml`
- `~/.cursor-local-assistant-v2/data/ca.crt` - `~/.cursor-local-assistant-v2/data/ca.crt`
- `~/.cursor-local-assistant-v2/data/ads/`
- `~/.cursor-local-assistant-v2/history/` - `~/.cursor-local-assistant-v2/history/`
- `~/.cursor-local-assistant-v2/logs/` - `~/.cursor-local-assistant-v2/logs/`
@@ -106,7 +104,6 @@ internal/backend/
- `config.yaml` 是用户配置 - `config.yaml` 是用户配置
- `data/ca.crt` 是注入给宿主的 CA 证书 - `data/ca.crt` 是注入给宿主的 CA 证书
- `data/ads/` 是广告包与资源缓存目录
- `history/` 是会话事实与全局 usage JSON 目录,不属于日志 - `history/` 是会话事实与全局 usage JSON 目录,不属于日志
- `logs/` 只保留必要文本运行日志 - `logs/` 只保留必要文本运行日志

View File

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

View File

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

View File

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

View File

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

View File

@@ -746,7 +746,16 @@ func validateConversationID(conversationID string) (string, error) {
if normalized == "" { if normalized == "" {
return "", fmt.Errorf("conversation_id is required") 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 "", fmt.Errorf("conversation_id must not contain path separators")
} }
return normalized, nil return normalized, nil

View File

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

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ func (manager *Manager) currentConfig() Config {
return DefaultConfig() return DefaultConfig()
} }
if current := manager.current.Load(); current != nil { if current := manager.current.Load(); current != nil {
return *current return cloneConfig(*current)
} }
return DefaultConfig() 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") return Config{}, fmt.Errorf("config manager is not initialized")
} }
manager.saveMu.Lock() manager.saveMu.Lock()
defer manager.saveMu.Unlock() locked := true
defer func() {
if locked {
manager.saveMu.Unlock()
}
}()
normalized, err := manager.store.Save(ctx, cfg) normalized, err := manager.store.Save(ctx, cfg)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
@@ -86,6 +91,8 @@ func (manager *Manager) Save(ctx context.Context, cfg Config) (Config, error) {
manager.lastReload = time.Now() manager.lastReload = time.Now()
manager.reloadError = "" manager.reloadError = ""
manager.reloadMu.Unlock() manager.reloadMu.Unlock()
manager.saveMu.Unlock()
locked = false
manager.notify(normalized) manager.notify(normalized)
return normalized, nil return normalized, nil
} }
@@ -104,13 +111,17 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
normalizedValue := strings.TrimSpace(value) normalizedValue := strings.TrimSpace(value)
// 在 saveMu 保护下完成 read-modify-write,避免与并发 Save 互相覆盖。 // 在 saveMu 保护下完成 read-modify-write,避免与并发 Save 互相覆盖。
manager.saveMu.Lock() manager.saveMu.Lock()
defer manager.saveMu.Unlock() locked := true
defer func() {
if locked {
manager.saveMu.Unlock()
}
}()
current := manager.currentConfig() current := manager.currentConfig()
if strings.TrimSpace(current.LastAgentModelHash) == normalizedValue { if strings.TrimSpace(current.LastAgentModelHash) == normalizedValue {
return nil return nil
} }
current.LastAgentModelHash = normalizedValue normalized, err := manager.store.updateLastAgentModelHash(normalizedValue)
normalized, err := manager.store.Save(ctx, current)
if err != nil { if err != nil {
return err return err
} }
@@ -120,6 +131,8 @@ func (manager *Manager) SaveLastAgentModelHash(ctx context.Context, value string
manager.lastReload = time.Now() manager.lastReload = time.Now()
manager.reloadError = "" manager.reloadError = ""
manager.reloadMu.Unlock() manager.reloadMu.Unlock()
manager.saveMu.Unlock()
locked = false
manager.notify(normalized) manager.notify(normalized)
return nil return nil
} }
@@ -164,7 +177,9 @@ func (manager *Manager) LegacyRuntimeSnapshot(_ context.Context) (legacyruntime.
adapters := make([]legacyruntime.ModelAdapterConfig, 0, len(cfg.ModelAdapters)) adapters := make([]legacyruntime.ModelAdapterConfig, 0, len(cfg.ModelAdapters))
for _, item := range cfg.ModelAdapters { for _, item := range cfg.ModelAdapters {
adapters = append(adapters, legacyruntime.ModelAdapterConfig{ 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, DisplayName: item.DisplayName,
Type: item.Type, Type: item.Type,
BaseURL: item.BaseURL, BaseURL: item.BaseURL,
@@ -204,7 +219,7 @@ func (manager *Manager) RouteMode(hasUpstreamURL bool) string {
} }
func (manager *Manager) setCurrent(cfg Config) { func (manager *Manager) setCurrent(cfg Config) {
next := cfg next := cloneConfig(cfg)
manager.current.Store(&next) manager.current.Store(&next)
} }
@@ -215,6 +230,13 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
manager.saveMu.Lock()
reloadSaveLocked := true
defer func() {
if reloadSaveLocked {
manager.saveMu.Unlock()
}
}()
now := time.Now() now := time.Now()
manager.reloadMu.Lock() manager.reloadMu.Lock()
if !manager.lastReload.IsZero() && now.Sub(manager.lastReload) < configHotReloadMinInterval { if !manager.lastReload.IsZero() && now.Sub(manager.lastReload) < configHotReloadMinInterval {
@@ -241,6 +263,8 @@ func (manager *Manager) reloadIfChanged(ctx context.Context) {
manager.reloadError = "" manager.reloadError = ""
manager.setCurrent(cfg) manager.setCurrent(cfg)
manager.reloadMu.Unlock() manager.reloadMu.Unlock()
manager.saveMu.Unlock()
reloadSaveLocked = false
manager.notify(cfg) manager.notify(cfg)
} }
@@ -250,7 +274,7 @@ func (manager *Manager) notify(cfg Config) {
manager.listenersMu.RUnlock() manager.listenersMu.RUnlock()
for _, listener := range listeners { for _, listener := range listeners {
if listener != nil { if listener != nil {
listener(cfg) listener(cloneConfig(cfg))
} }
} }
} }

View File

@@ -16,8 +16,8 @@ const (
// 默认值越过服务方上限会直接返回 400(exceeds the configured maximum output length)。 // 默认值越过服务方上限会直接返回 400(exceeds the configured maximum output length)。
defaultChannelMaxTokens = 278_000 defaultChannelMaxTokens = 278_000
defaultChannelAnthropicMaxTokens = 131_072 defaultChannelAnthropicMaxTokens = 131_072
defaultChannelThinkingBudget = 4_096 defaultChannelThinkingBudget = 4_096
defaultChannelAnthropicEffort = "xhigh" defaultChannelAnthropicEffort = "xhigh"
) )
func (manager *Manager) SelectChannelForModel(_ context.Context, modelID string) (*legacyruntime.ResolvedChannel, error) { func (manager *Manager) SelectChannelForModel(_ context.Context, modelID string) (*legacyruntime.ResolvedChannel, error) {

View File

@@ -0,0 +1,33 @@
package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
)
var ErrConfigConflict = errors.New("CONFIG_CONFLICT: 配置已在其他窗口更改,请重新加载后再保存;当前编辑内容已保留")
func cloneConfig(cfg Config) Config {
cfg.ModelAdapters = append([]ModelAdapterConfig(nil), cfg.ModelAdapters...)
if cfg.HomeMetrics.PersistentMode != nil {
value := *cfg.HomeMetrics.PersistentMode
cfg.HomeMetrics.PersistentMode = &value
}
return cfg
}
func withRevision(cfg Config) Config {
cfg = cloneConfig(cfg)
view := cloneConfig(cfg)
view.Revision = ""
view.LastAgentModelHash = ""
for i := range view.ModelAdapters {
view.ModelAdapters[i].ID = ""
}
data, _ := json.Marshal(view)
sum := sha256.Sum256(data)
cfg.Revision = hex.EncodeToString(sum[:])
return cfg
}

View File

@@ -0,0 +1,83 @@
package config
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestConfigCASAndMetadataIsolation(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
m, err := NewManager(context.Background(), store)
if err != nil {
t.Fatal(err)
}
a, _ := m.Load(nil)
b, _ := m.Load(nil)
if a.Revision == "" {
t.Fatal("missing revision")
}
if err = m.SaveLastAgentModelHash(nil, "model-new"); err != nil {
t.Fatal(err)
}
a.CLIHTTPSListenAddr = "127.0.0.1:39099"
first, err := m.Save(nil, a)
if err != nil {
t.Fatal(err)
}
if first.LastAgentModelHash != "model-new" {
t.Fatal("UI overwrote backend metadata")
}
b.HomeMetrics.RefreshIntervalSeconds = 11
if _, err = m.Save(nil, b); !errors.Is(err, ErrConfigConflict) {
t.Fatalf("stale save accepted: %v", err)
}
current, _ := m.Load(nil)
if current.CLIHTTPSListenAddr != "127.0.0.1:39099" {
t.Fatal("CLI port lost")
}
data, _ := os.ReadFile(store.Path())
if strings.Contains(string(data), "revision:") {
t.Fatal("revision persisted in YAML")
}
if current.Revision == a.Revision {
t.Fatal("revision did not change")
}
}
func TestConcurrentSavesHaveExactlyOneWinner(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "config.yaml"), "")
m, err := NewManager(context.Background(), store)
if err != nil {
t.Fatal(err)
}
a, _ := m.Load(nil)
b := cloneConfig(a)
a.HomeMetrics.RefreshIntervalSeconds = 11
b.HomeMetrics.RefreshIntervalSeconds = 12
var wg sync.WaitGroup
results := make(chan error, 2)
for _, cfg := range []Config{a, b} {
wg.Add(1)
go func(c Config) { defer wg.Done(); _, err := m.Save(nil, c); results <- err }(cfg)
}
wg.Wait()
close(results)
success, conflict := 0, 0
for err := range results {
if err == nil {
success++
} else if errors.Is(err, ErrConfigConflict) {
conflict++
} else {
t.Fatal(err)
}
}
if success != 1 || conflict != 1 {
t.Fatalf("success=%d conflict=%d", success, conflict)
}
}

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"context" "context"
"cursor/internal/securefile"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -75,7 +76,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
if err := store.saveLocked(defaultConfig); err != nil { if err := store.saveLocked(defaultConfig); err != nil {
return DefaultConfig(), err return DefaultConfig(), err
} }
return defaultConfig, nil return withRevision(defaultConfig), nil
} }
return DefaultConfig(), fmt.Errorf("读取用户配置失败: %w", err) return DefaultConfig(), fmt.Errorf("读取用户配置失败: %w", err)
} }
@@ -93,7 +94,7 @@ func (store *Store) Load(_ context.Context) (Config, error) {
return DefaultConfig(), err return DefaultConfig(), err
} }
} }
return normalized, nil return withRevision(normalized), nil
} }
func (store *Store) Save(_ context.Context, cfg Config) (Config, error) { 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() store.mu.Lock()
defer store.mu.Unlock() 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 { if err := store.saveLocked(normalized); err != nil {
return Config{}, err return Config{}, err
} }
return normalized, nil return withRevision(normalized), nil
} }
func (store *Store) saveLocked(normalized Config) error { func (store *Store) saveLocked(normalized Config) error {
@@ -125,11 +136,7 @@ func (store *Store) saveLocked(normalized Config) error {
return fmt.Errorf("序列化用户配置失败: %w", err) return fmt.Errorf("序列化用户配置失败: %w", err)
} }
tempPath := store.path + ".tmp" if err := securefile.Write(store.path, data); err != nil {
if err := writeFileSync(tempPath, data); err != nil {
return fmt.Errorf("写入临时配置失败: %w", err)
}
if err := os.Rename(tempPath, store.path); err != nil {
return fmt.Errorf("保存用户配置失败: %w", err) return fmt.Errorf("保存用户配置失败: %w", err)
} }
return nil return nil
@@ -181,3 +188,31 @@ func yamlHasKey(raw []byte, key string) bool {
} }
return false return false
} }
func (store *Store) readCurrentLocked() (Config, error) {
data, err := os.ReadFile(store.path)
if errors.Is(err, os.ErrNotExist) {
return DefaultConfig(), nil
}
if err != nil {
return Config{}, err
}
var current Config
if err = yaml.Unmarshal(data, &current); err != nil {
return Config{}, err
}
return NormalizeConfig(current)
}
func (store *Store) updateLastAgentModelHash(value string) (Config, error) {
store.mu.Lock()
defer store.mu.Unlock()
cfg, err := store.readCurrentLocked()
if err != nil {
return Config{}, err
}
cfg.LastAgentModelHash = value
if err = store.saveLocked(cfg); err != nil {
return Config{}, err
}
return withRevision(cfg), nil
}

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"cursor/internal/logger"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -62,6 +63,7 @@ type HomeMetricsConfig struct {
} }
type Config struct { type Config struct {
Revision string `json:"revision" yaml:"-"`
Log bool `json:"log" yaml:"log"` Log bool `json:"log" yaml:"log"`
ProviderStreamIdleTimeout int `json:"providerStreamIdleTimeout" yaml:"providerStreamIdleTimeout"` ProviderStreamIdleTimeout int `json:"providerStreamIdleTimeout" yaml:"providerStreamIdleTimeout"`
BackendListenAddr string `json:"backendListenAddr" yaml:"backendListenAddr"` BackendListenAddr string `json:"backendListenAddr" yaml:"backendListenAddr"`
@@ -139,6 +141,13 @@ func NormalizeConfig(input Config) (Config, error) {
} }
output.TabServerBaseURL = normalizeTabServerURL(input.TabServerBaseURL) output.TabServerBaseURL = normalizeTabServerURL(input.TabServerBaseURL)
output.TabServerToken = strings.TrimSpace(input.TabServerToken) 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) adapters, err := NormalizeModelAdapterConfigs(input.ModelAdapters)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
@@ -156,7 +165,7 @@ func normalizeTabServerURL(value string) string {
if err != nil { if err != nil {
return "" return ""
} }
if parsed.Scheme != "http" && parsed.Scheme != "https" { if parsed.User != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return "" return ""
} }
parsed.Path = strings.TrimRight(parsed.Path, "/") parsed.Path = strings.TrimRight(parsed.Path, "/")
@@ -193,6 +202,7 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
AnthropicMaxTokens: normalizeMaxCompletionTokens(item.AnthropicMaxTokens), AnthropicMaxTokens: normalizeMaxCompletionTokens(item.AnthropicMaxTokens),
ThinkingBudgetTokens: normalizeMaxCompletionTokens(item.ThinkingBudgetTokens), ThinkingBudgetTokens: normalizeMaxCompletionTokens(item.ThinkingBudgetTokens),
} }
logger.RegisterSecrets(next.APIKey)
if next.Type == "openai" { if next.Type == "openai" {
next.OpenAIExtraParamsEnabled = item.OpenAIExtraParamsEnabled next.OpenAIExtraParamsEnabled = item.OpenAIExtraParamsEnabled
next.OpenAIExtraParamsJSON = strings.TrimSpace(item.OpenAIExtraParamsJSON) next.OpenAIExtraParamsJSON = strings.TrimSpace(item.OpenAIExtraParamsJSON)
@@ -203,6 +213,15 @@ func NormalizeModelAdapterConfigs(input []ModelAdapterConfig) ([]ModelAdapterCon
} }
next.CustomHeadersEnabled = item.CustomHeadersEnabled next.CustomHeadersEnabled = item.CustomHeadersEnabled
next.CustomHeadersJSON = strings.TrimSpace(item.CustomHeadersJSON) 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 { switch {
case next.DisplayName == "": case next.DisplayName == "":
return nil, errors.New("模型适配器 displayName 不能为空") return nil, errors.New("模型适配器 displayName 不能为空")

View File

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

View File

@@ -25,11 +25,6 @@ import (
//go:embed ca.crt //go:embed ca.crt
var embeddedCACertPEM []byte var embeddedCACertPEM []byte
// embeddedCAKeyPEM 表示当前模块中的 embeddedCAKeyPEM 状态值。
//
//go:embed ca.key
var embeddedCAKeyPEM []byte
// Manager 定义了当前模块中的 Manager 类型。 // Manager 定义了当前模块中的 Manager 类型。
type Manager struct { type Manager struct {
// caCert 表示当前声明中的 caCert。 // caCert 表示当前声明中的 caCert。
@@ -52,27 +47,23 @@ func NewManager(caCertPath, caKeyPath string) (*Manager, error) {
return NewManagerFromPEM(certPEM, keyPEM) return NewManagerFromPEM(certPEM, keyPEM)
} }
// NewEmbeddedManager 用于处理与 NewEmbeddedManager 相关的逻辑。
func NewEmbeddedManager() (*Manager, error) {
return NewManagerFromPEM(embeddedCACertPEM, embeddedCAKeyPEM)
}
// EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。 // EmbeddedCACertPEM 用于处理与 EmbeddedCACertPEM 相关的逻辑。
func EmbeddedCACertPEM() []byte { func EmbeddedCACertPEM() []byte {
return cloneBytes(embeddedCACertPEM) return cloneBytes(embeddedCACertPEM)
} }
// EmbeddedCAKeyPEM 用于处理与 EmbeddedCAKeyPEM 相关的逻辑。
func EmbeddedCAKeyPEM() []byte {
return cloneBytes(embeddedCAKeyPEM)
}
// NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。 // NewManagerFromPEM 用于处理与 NewManagerFromPEM 相关的逻辑。
func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) { func NewManagerFromPEM(caCertPEM, caKeyPEM []byte) (*Manager, error) {
if _, err := tls.X509KeyPair(caCertPEM, caKeyPEM); err != nil {
return nil, err
}
caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM) caCert, caKey, err := loadCAFromPEM(caCertPEM, caKeyPEM)
if err != nil { if err != nil {
return nil, err 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 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() m.mu.Lock()
defer m.mu.Unlock() 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 return cert, nil
} }
@@ -119,6 +110,12 @@ func (m *Manager) CertificateForServerName(serverName string) (*tls.Certificate,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true, 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 { if len(m.caCert.SubjectKeyId) > 0 {
leaf.AuthorityKeyId = append([]byte(nil), m.caCert.SubjectKeyId...) leaf.AuthorityKeyId = append([]byte(nil), m.caCert.SubjectKeyId...)
} }

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,15 @@
package cursor package cursor
import ( import (
"cursor/internal/securefile"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"cursor/internal/appdata" "cursor/internal/appdata"
@@ -68,88 +71,10 @@ func DetectCLIInstallPath() string {
return "" return ""
} }
// cliPreloadFilename 是 preload 脚本的文件名。 const cliPreloadFilename = "tls-redirect.cjs"
const cliPreloadFilename = "tls-bypass.cjs"
// cliPreloadScript 是注入到 Node.js 的 preload 脚本。 //go:embed cli_preload.cjs
// var cliPreloadScript string
// 工作原理:
// 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);
`
// writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。 // writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。
func writeCLIPreloadScript() (string, error) { func writeCLIPreloadScript() (string, error) {
@@ -158,7 +83,7 @@ func writeCLIPreloadScript() (string, error) {
return "", fmt.Errorf("创建 preload 目录失败: %w", err) return "", fmt.Errorf("创建 preload 目录失败: %w", err)
} }
preloadPath := filepath.Join(wrapperDir, cliPreloadFilename) 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 "", fmt.Errorf("写入 preload 脚本失败: %w", err)
} }
return preloadPath, nil return preloadPath, nil
@@ -207,15 +132,21 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
var content string var content string
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
content = buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath) 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 { } else {
content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath) content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath)
} }
wrapperPath := CLIWrapperPath() 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) return fmt.Errorf("写入 wrapper 脚本失败: %w", err)
} }
if err := os.Chmod(wrapperPath, 0700); err != nil {
return err
}
logger.Infof( logger.Infof(
"writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s", "writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s",
wrapperPath, port, caCertPath, cliPath, preloadPath, wrapperPath, port, caCertPath, cliPath, preloadPath,
@@ -225,6 +156,11 @@ func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
// ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。 // ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。
func ClearCLIProxyWrapper() error { 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() wrapperPath := CLIWrapperPath()
if _, err := os.Stat(wrapperPath); err != nil { if _, err := os.Stat(wrapperPath); err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
@@ -387,31 +323,41 @@ func ClearCLIAuthFile() error {
} }
func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string { func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
cliDir := filepath.Dir(cliPath) return "@echo off\r\nsetlocal DisableDelayedExpansion\r\n\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -File \"%~dp0launch.ps1\" %*\r\n"
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)
} }
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 { func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
return fmt.Sprintf(`#!/bin/sh return fmt.Sprintf(`#!/bin/sh
unset HTTP_PROXY unset HTTP_PROXY HTTPS_PROXY NODE_TLS_REJECT_UNAUTHORIZED
unset HTTPS_PROXY export CLI_HTTPS_PORT=%s
export CLI_HTTPS_PORT="%s" export LOCAL_CURSOR_ORIGINAL_CA_FILE="${NODE_EXTRA_CA_CERTS-}"
export NODE_EXTRA_CA_CERTS="%s" export NODE_EXTRA_CA_CERTS=%s
export NODE_TLS_REJECT_UNAUTHORIZED=0 if [ "$LOCAL_CURSOR_ORIGINAL_CA_FILE" = "$NODE_EXTRA_CA_CERTS" ]; then unset LOCAL_CURSOR_ORIGINAL_CA_FILE; fi
export NODE_OPTIONS="--require=%s" case "${NODE_OPTIONS-}" in *tls-bypass.cjs*) unset NODE_OPTIONS;; esac
export CURSOR_AUTH_TOKEN="%s" export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }"%s
exec "%s" "$@" export CURSOR_AUTH_TOKEN=%s
`, port, caCertPath, preloadPath, authToken, cliPath) exec %s "$@"
`, shellLiteral(port), shellLiteral(caCertPath), shellLiteral("--require="+strconv.Quote(preloadPath)), shellLiteral(authToken), shellLiteral(cliPath))
} }

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ package cursor
import ( import (
"crypto/sha1" "crypto/sha1"
"crypto/x509" "crypto/x509"
"cursor/internal/certs"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"os/exec" "os/exec"
@@ -84,15 +85,23 @@ func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
// EnsureCACertInstalled 确保 CA 证书已安装到 macOS 登录钥匙串。 // EnsureCACertInstalled 确保 CA 证书已安装到 macOS 登录钥匙串。
func EnsureCACertInstalled(certPEM []byte, certPath string) error { func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM) return migrateCATrust(certPEM, isCACertInstalled, func() error {
if err != nil { if err := installCACertToDarwinKeychain(certPEM, certPath); err != nil {
return fmt.Errorf("检查 macOS 证书安装状态失败: %w", err) return err
} }
if installed { legacy, err := getCertSHA1Fingerprint(certs.EmbeddedCACertPEM())
logger.Infof("ensureCACertInstalled: cert already installed in macOS login keychain, skipping") 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 return nil
} })
logger.Infof("ensureCACertInstalled: cert not installed in macOS login keychain, installing...")
return installCACertToDarwinKeychain(certPEM, certPath)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,12 +30,6 @@ var injectedCursorSettingsKeys = []string{
// EnsureCACertFile 用于处理与 EnsureCACertFile 相关的逻辑。 // EnsureCACertFile 用于处理与 EnsureCACertFile 相关的逻辑。
func EnsureCACertFile(certPEM []byte, currentPath string) (string, error) { func EnsureCACertFile(certPEM []byte, currentPath string) (string, error) {
certPath := appdata.CACertFilePath() 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 { if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil {
return "", fmt.Errorf("创建证书配置目录失败: %w", err) return "", fmt.Errorf("创建证书配置目录失败: %w", err)
@@ -65,6 +59,9 @@ func samePath(left string, right string) bool {
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" { if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return false return false
} }
if runtime.GOOS == "windows" {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
return filepath.Clean(left) == filepath.Clean(right) return filepath.Clean(left) == filepath.Clean(right)
} }
@@ -117,125 +114,6 @@ func ClearSystemNodeExtraCACerts() error {
return nil 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 相关的逻辑。 // resolveCursorSettingsPath 用于处理与 resolveCursorSettingsPath 相关的逻辑。
func resolveCursorSettingsPath() (string, error) { func resolveCursorSettingsPath() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()

View File

@@ -5,12 +5,16 @@ package cursor
import ( import (
"crypto/sha1" "crypto/sha1"
"crypto/x509" "crypto/x509"
"cursor/internal/certs"
"encoding/base64"
"encoding/binary"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"syscall" "syscall"
"unicode/utf16"
"cursor/internal/logger" "cursor/internal/logger"
) )
@@ -147,16 +151,62 @@ func installCACertToWindowsStore(certPEM []byte, certPath string) error {
// EnsureCACertInstalled 确保证书已安装到 Windows 系统信任存储。 // EnsureCACertInstalled 确保证书已安装到 Windows 系统信任存储。
func EnsureCACertInstalled(certPEM []byte, certPath string) error { func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM) return migrateCATrust(certPEM, isCAInstalledAnyStore, func() error {
if err != nil { thumb, err := getCertThumbprint(certPEM)
return fmt.Errorf("检查系统证书安装状态失败: %w", err) if err != nil {
} return err
}
if installed { legacy, err := getCertThumbprint(certs.EmbeddedCACertPEM())
logger.Infof("ensureCACertInstalled: cert already installed in system store, skipping") 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 return nil
} })
}
logger.Infof("ensureCACertInstalled: cert not installed in system store, installing...")
return installCACertToWindowsStore(certPEM, certPath) func isCAInstalledAnyStore(certPEM []byte) (bool, error) {
thumb, err := getCertThumbprint(certPEM)
if err != nil {
return false, err
}
script := "$ErrorActionPreference='Stop'; (Test-Path " + quotePowerShellLiteral(`Cert:\LocalMachine\Root\`+thumb) + ") -or (Test-Path " + quotePowerShellLiteral(`Cert:\CurrentUser\Root\`+thumb) + ")"
cmd := exec.Command(windowsPowerShellExe, "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = hideWindow()
out, err := cmd.Output()
if err != nil {
return false, err
}
return strings.EqualFold(strings.TrimSpace(string(out)), "True"), nil
}
func windowsCAMigrationBody(certPath, thumb, legacy string) string {
return fmt.Sprintf(`$ErrorActionPreference='Stop'
$c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(%s)
if ($c.Thumbprint -ne %s) { throw 'CA fingerprint mismatch' }
$s = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root','LocalMachine')
try {
$s.Open('ReadWrite'); $s.Add($c)
if ($s.Certificates.Find('FindByThumbprint',%s,$false).Count -eq 0) { throw 'CA installation failed' }
foreach ($old in @($s.Certificates.Find('FindByThumbprint',%s,$false))) { $s.Remove($old) }
} finally { $s.Close() }
`, quotePowerShellLiteral(certPath), quotePowerShellLiteral(thumb), quotePowerShellLiteral(thumb), quotePowerShellLiteral(legacy))
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

19
main.go
View File

@@ -2,8 +2,10 @@ package main
import ( import (
"cursor/internal/app" "cursor/internal/app"
"cursor/internal/buildinfo"
"cursor/internal/logger" "cursor/internal/logger"
"embed" "embed"
"fmt"
"os" "os"
) )
@@ -17,11 +19,22 @@ var appIcon []byte
var trayIcon []byte var trayIcon []byte
func main() { 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() logger.Init()
if err := app.Run(app.EmbeddedResources{ if err := app.Run(app.EmbeddedResources{
Assets: assets, DisableAutoStart: disableAutoStart,
AppIcon: appIcon, Assets: assets,
TrayIcon: trayIcon, AppIcon: appIcon,
TrayIcon: trayIcon,
}); err != nil { }); err != nil {
logger.Errorf("app run failed: %v", err) logger.Errorf("app run failed: %v", err)
os.Exit(1) os.Exit(1)

View File

@@ -1,7 +1,9 @@
# Local-Cursor # Local-Cursor v1.1.9
- 个人使用版本 - 改用本机独立 CA,首次升级需重新授权信任;恢复 CLI TLS 验证并支持中文、空格和引号路径。
- 移除广告系统 - 修复配置端口丢失、多窗口覆盖、损坏配置被删除、最近项目记录字段丢失和异常关闭后端口占用。
- 移除自动更新 - 移除日志与浏览器缓存中的敏感配置;保护本机私钥和日志,限制无关网站访问本地 RPC。
- 默认端口改为 28090/28080,避免与原版冲突 - 修复 Protobuf 锁复制和用量统计测试,完善模型请求头、流式响应、取消及重定向处理。
- Tab 补全服务改为可配置 - 独立 Tab 服务增加必填客户端令牌;远程部署需同步配置 clientToken/tabServerToken。
- 加固 Skills 文件操作,升级 Go 和前端安全依赖,固定可重现构建工具链与版本资源。
- 仅提供 Windows x64 便携版。详细测试、平台限制与升级说明见 docs/review-v1.1.9.md。

View File

@@ -50,10 +50,12 @@ var releaseAssets = []assetSpec{
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
exitf("usage: go run ./scripts/release <version|notes|manifest> [flags]") exitf("usage: go run ./scripts/release <version|notes|manifest|sync-metadata> [flags]")
} }
switch os.Args[1] { switch os.Args[1] {
case "sync-metadata":
runMetadata(os.Args[2:])
case "version": case "version":
runVersion(os.Args[2:]) runVersion(os.Args[2:])
case "notes": case "notes":

View File

@@ -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", `(<assemblyIdentity[^>]*version=")[^"]*(")`, core + ".0"},
{"build/windows/nsis/wails_tools.nsh", `(!define INFO_PRODUCTVERSION ")[^"]*(")`, core},
{"build/darwin/Info.plist", `(<key>CFBundle(?:ShortVersionString|Version)</key>\s*<string>)[^<]*(</string>)`, version},
{"build/darwin/Info.dev.plist", `(<key>CFBundle(?:ShortVersionString|Version)</key>\s*<string>)[^<]*(</string>)`, 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)
}
}

View File

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